Add the Reworked Terminal as an optional embedded TUI engine on IntelliJ Platform 2026.2 and newer, with feature parity for clipboard integration, file drag and drop, and file navigation.
+
+
+
Breaking Changes
+
+
Drop support for IntelliJ Platform versions earlier than 2025.3.
+
+
+
Changed
+
+
Standardize generated IDE file references on project-relative ./path/to/File.kt:line syntax.
+
+
+
Fixed
+
+
Improve embedded terminal cleanup and restart behavior when startup fails, a session terminates, or the terminal engine changes.
+
Send selected folders as @path/ and multi-line editor selections as @path#Lstart-end so OpenCode recognizes them correctly.
+
+
## [2.2.0] - 2026-06-25
Fixed
diff --git a/README.md b/README.md
index 24dc2af..9773350 100644
--- a/README.md
+++ b/README.md
@@ -103,8 +103,7 @@ it.
### Permission prompts
-When any agent requests permission, a prompt appears at the bottom of
-the panel with three choices:
+When any agent requests permission, a prompt appears at the bottom of the panel with three choices:
- **Allow** — permit this one request
- **Allow Always** — permit this type of request for the rest of the session
@@ -114,7 +113,7 @@ Responses are sent back to the server immediately.
## Requirements
-- A JetBrains IDE based on IntelliJ Platform 2024.3.7 or later
+- A JetBrains IDE based on IntelliJ Platform 2025.3 or later
- [OpenCode CLI](https://opencode.ai/docs) version `1.16.0+` installed and on `PATH` (or the path configured in
settings)
@@ -126,8 +125,7 @@ OpenCode Relay will warn you when JetBrains MCP is disabled or when a running Op
To enable JetBrains MCP:
-1. Open **Settings | Tools | MCP Server** in your JetBrains
- IDE and enable the server.
+1. Open **Settings | Tools | MCP Server** in your JetBrains IDE and enable the server.
2. Note the MCP server URL or port shown in the IDE settings.
3. Add a `jetbrains` MCP server to your `opencode.json`.
4. Restart the OpenCode server so it reloads the config.
@@ -147,8 +145,8 @@ Basic `opencode.json` example:
}
```
-Edit `url` to match the host, port, and path from **Settings | Tools | MCP Server**. If your IDE shows a different
-port, replace `64342` with that port.
+Edit `url` to match the host, port, and path from **Settings | Tools | MCP Server**. If your IDE shows a different port,
+replace `64342` with that port.
### MCP Tips
diff --git a/build.gradle.kts b/build.gradle.kts
index 4315bba..28d6695 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -47,7 +47,7 @@ val liveTest by sourceSets.creating {
dependencies {
intellijPlatform {
- intellijIdea("2024.3.7")
+ intellijIdea("2025.3.3")
pluginVerifier()
bundledPlugin("org.jetbrains.plugins.terminal")
compatiblePlugin("com.intellij.mcpServer")
@@ -66,6 +66,8 @@ configurations[liveTest.implementationConfigurationName].extendsFrom(configurati
configurations[liveTest.runtimeOnlyConfigurationName].extendsFrom(configurations.testRuntimeOnly.get())
intellijPlatform {
+ instrumentCode = false
+
pluginConfiguration {
id = "com.ashotn.opencode-relay"
name = "OpenCode Relay"
@@ -80,14 +82,14 @@ intellijPlatform {
}
ideaVersion {
- sinceBuild = "243"
+ sinceBuild = "253"
untilBuild = provider { null }
}
}
pluginVerification {
ides {
- create(org.jetbrains.intellij.platform.gradle.IntelliJPlatformType.IntellijIdea, "2024.3.7")
+ create(org.jetbrains.intellij.platform.gradle.IntelliJPlatformType.IntellijIdea, "2025.3.3")
}
}
@@ -101,6 +103,22 @@ intellijPlatform {
}
val sandboxProject = layout.buildDirectory.dir("sandbox-project").get().asFile
+val idea262Home = providers.gradleProperty("idea262Home")
+ .orElse(providers.environmentVariable("IDEA_262_HOME"))
+ .orElse(providers.provider {
+ "${System.getProperty("user.home")}/.local/share/JetBrains/Toolbox/apps/intellij-idea"
+ })
+
+val runIde262 by intellijPlatformTesting.runIde.registering {
+ localPath = layout.dir(idea262Home.map(::file))
+ sandboxDirectory = layout.buildDirectory.dir("idea-sandbox-262")
+
+ task {
+ description = "Runs the plugin in a local IntelliJ IDEA 2026.2 sandbox."
+ doFirst { sandboxProject.mkdirs() }
+ args(sandboxProject.absolutePath)
+ }
+}
fun mainOutputFriendPaths(): String =
sourceSets["main"].output.classesDirs.files.joinToString(",") { it.absolutePath }
@@ -154,9 +172,4 @@ tasks {
kotlin {
jvmToolchain(21)
-
- sourceSets.named("main") {
- kotlin.exclude("com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt")
- kotlin.exclude("com/ashotn/opencode/relay/terminal/NewSessionTerminalAllowedActionsProvider.kt")
- }
}
diff --git a/opencode.json b/opencode.json
index 7c118f3..d16be7a 100644
--- a/opencode.json
+++ b/opencode.json
@@ -10,11 +10,5 @@
"permission": {
"jetbrains_execute_terminal_command": "ask",
"jetbrains_execute_run_configuration": "allow"
- },
- "agent": {
- "explore": {
- "mode": "subagent",
- "model": "openai/gpt-5.4-mini"
- }
}
}
diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEnvironmentFactory.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEnvironmentFactory.kt
index 73de5be..c936c0d 100644
--- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEnvironmentFactory.kt
+++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/OpenCodeTestEnvironmentFactory.kt
@@ -152,7 +152,6 @@ class OpenCodeTestEnvironment(
private val healthApiClient = HealthApiClient()
private var server: OpenCodeTestServer? = null
- private var preserveOnClose = false
fun startServer(timeoutMs: Long = 15_000): OpenCodeTestServer {
check(server == null) { "Scenario environment already has a running server" }
@@ -217,13 +216,7 @@ class OpenCodeTestEnvironment(
override fun close() {
server?.close()
server = null
- if (!preserveOnClose) {
- scenarioRoot.deleteRecursively()
- }
- }
-
- fun preserveForDiagnostics() {
- preserveOnClose = true
+ scenarioRoot.deleteRecursively()
}
fun diagnosticsSummary(maxLogLines: Int = 40): String = buildString {
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 62ccd55..31b0721 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
@@ -10,18 +10,11 @@ import com.ashotn.opencode.relay.api.session.SessionApiClient
import com.ashotn.opencode.relay.api.session.SessionDiffFile
import com.ashotn.opencode.relay.api.session.SessionDiffSnapshot
import com.ashotn.opencode.relay.api.transport.ApiResult
-import com.ashotn.opencode.relay.core.CoreDiffStateHarness
-import com.ashotn.opencode.relay.core.DiffPipelineHarness
-import com.ashotn.opencode.relay.core.DiffHunk
-import com.ashotn.opencode.relay.ipc.OpenCodeEvent
import com.ashotn.opencode.relay.ipc.SessionDiffStatus
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import java.nio.file.Path
-import java.util.concurrent.ConcurrentLinkedQueue
-import java.util.concurrent.atomic.AtomicReference
-import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText
@@ -35,25 +28,10 @@ class OpenCodeDiffLiveTest(
) {
private data class ChildDiffs(
- val sessions: List,
- val diffsBySessionId: Map,
val fileToChildSessionId: Map,
val diffSummaryRoleByFile: Map,
)
- private data class MessageDiffProbeContext(
- val sessionClient: SessionApiClient,
- val port: Int,
- val sessionId: String,
- val repoRoot: Path,
- )
-
- private data class MessageDiffProbeSnapshot(
- val messageId: String,
- val eventFiles: List,
- val fetchedFiles: List,
- )
-
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
@@ -82,22 +60,6 @@ class OpenCodeDiffLiveTest(
val createDiffFile = requireSessionDiffFile(sessionClient, server.port, sessionId, "hello.txt")
assertEquals(SessionDiffStatus.ADDED, createDiffFile.status)
- val createPreview = awaitFileDiffPreview(
- sessionClient = sessionClient,
- port = server.port,
- sessionId = sessionId,
- projectBase = environment.repoRoot.toString(),
- absFilePath = helloFile.toString(),
- )
- assertInlineDiffFromServerPayload(
- repoRoot = environment.repoRoot.toString(),
- sessionId = sessionId,
- diffFile = createDiffFile,
- expectedRemoved = "",
- expectedAdded = "Hello World\n",
- )
- assertPreviewMatchesServerDiff(createPreview, createDiffFile)
-
submitPromptAndAwaitTurn(
sessionClient = sessionClient,
events = events,
@@ -119,21 +81,6 @@ class OpenCodeDiffLiveTest(
val updateDiffFile = requireSessionDiffFile(sessionClient, server.port, sessionId, "hello.txt")
assertEquals(SessionDiffStatus.ADDED, updateDiffFile.status)
- val updatePreview = awaitFileDiffPreview(
- sessionClient = sessionClient,
- port = server.port,
- sessionId = sessionId,
- projectBase = environment.repoRoot.toString(),
- absFilePath = helloFile.toString(),
- )
- assertInlineDiffFromServerPayload(
- repoRoot = environment.repoRoot.toString(),
- sessionId = sessionId,
- diffFile = updateDiffFile,
- expectedRemoved = "",
- expectedAdded = "Goodbye World\n",
- )
- assertPreviewMatchesServerDiff(updatePreview, updateDiffFile)
}
}
@@ -168,91 +115,11 @@ class OpenCodeDiffLiveTest(
assertEquals(SessionDiffStatus.MODIFIED, diffFile.status)
assertEquals("Alpha\nBravo\nCharlie\n", normalizeNewlinesOnly(diffFile.before))
assertEquals("Alpha\nBeta\nCharlie\n", normalizeNewlinesOnly(diffFile.after))
-
- val preview = awaitFileDiffPreview(
- sessionClient = sessionClient,
- port = server.port,
- sessionId = sessionId,
- projectBase = environment.repoRoot.toString(),
- absFilePath = noteFile.toString(),
- )
- assertPreviewMatchesServerDiff(preview, diffFile)
- assertInlineDiffFromServerPayload(
- repoRoot = environment.repoRoot.toString(),
- sessionId = sessionId,
- diffFile = diffFile,
- expectedRemoved = "Bravo",
- expectedAdded = "Beta",
- )
- }
- }
-
- @Test
- fun `removes middle chunk from longer file and preserves diff semantics`() {
- 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(
- sessionClient = sessionClient,
- events = events,
- port = server.port,
- sessionId = sessionId,
- turnTimeoutMs = 60_000,
- text = prompt,
- )
-
- assertFileText(longFile, expected)
-
- val diffFile = requireSessionDiffFile(sessionClient, server.port, sessionId, "numbers.txt")
- assertEquals(SessionDiffStatus.MODIFIED, diffFile.status)
- assertEquals(normalizeNewlinesOnly(original), normalizeNewlinesOnly(diffFile.before))
- assertEquals(normalizeNewlinesOnly(expected), normalizeNewlinesOnly(diffFile.after))
-
- val preview = awaitFileDiffPreview(
- sessionClient = sessionClient,
- port = server.port,
- sessionId = sessionId,
- projectBase = environment.repoRoot.toString(),
- absFilePath = longFile.toString(),
- timeoutMs = 15_000,
- )
- assertPreviewMatchesServerDiff(preview, diffFile)
- assertInlineDiffFromServerPayload(
- repoRoot = environment.repoRoot.toString(),
- sessionId = sessionId,
- diffFile = diffFile,
- expectedRemoved = removedBlock.removeSuffix("\n"),
- expectedAdded = "",
- )
}
}
@Test
- fun `sub-agent edits are visible under root session diff state`() {
+ fun `sub-agent edits are attributed to child session diffs`() {
withLiveSession(version, allowTask = true) { environment, server, sessionClient, events, sessionId ->
val expectedFiles = linkedMapOf(
"live-subagents/alpha.txt" to "alpha from sub-agent\n",
@@ -286,7 +153,6 @@ class OpenCodeDiffLiveTest(
rootSessionId = sessionId,
repoRoot = environment.repoRoot,
expectedRelativePaths = expectedFiles.keys,
- timeoutMs = 30_000,
)
assertEquals(
expectedFiles.keys,
@@ -302,181 +168,12 @@ class OpenCodeDiffLiveTest(
childDiffs.diffSummaryRoleByFile,
"child diff summaries should be carried by user messages, matching the parser filter",
)
-
- val coreVisible = applyRealDiffsToCoreState(
- projectBase = environment.repoRoot.toString(),
- rootSessionId = sessionId,
- sessions = childDiffs.sessions,
- diffsBySessionId = childDiffs.diffsBySessionId,
- )
- val expectedAbsFiles = expectedFiles.keys.map { environment.repoRoot.resolve(it).toString() }.toSet()
-
- assertEquals(
- expectedAbsFiles,
- coreVisible.visibleFiles.intersect(expectedAbsFiles),
- "root session file list should include all sub-agent edits",
- )
- assertEquals(
- expectedAbsFiles,
- coreVisible.liveVisibleFiles.intersect(expectedAbsFiles),
- "root session live diff state should include all sub-agent edits simultaneously",
- )
- }
- }
-
- @Test
- fun `python multi-file turn does not lose later message diff updates`() {
- if (!version.startsWith("1.16.")) return
-
- val probeContext = AtomicReference()
- val snapshots = ConcurrentLinkedQueue()
-
- withLiveSession(
- version = version,
- onEvent = { event ->
- val context = probeContext.get() ?: return@withLiveSession
- if (event !is OpenCodeEvent.MessageDiffAvailable || event.sessionId != context.sessionId) return@withLiveSession
-
- val fetchedFiles = when (val result = context.sessionClient.fetchSessionDiffSnapshot(
- port = context.port,
- sessionId = event.sessionId,
- messageId = event.messageId,
- )) {
- is ApiResult.Success -> result.value.files.map { normalizeDiffPath(context.repoRoot, it.file) }
- is ApiResult.Failure -> emptyList()
- }
- snapshots.add(
- MessageDiffProbeSnapshot(
- messageId = event.messageId,
- eventFiles = event.files.map { it.replace('\\', '/') }.sorted(),
- fetchedFiles = fetchedFiles.sorted(),
- )
- )
- },
- ) { environment, server, sessionClient, events, sessionId ->
- probeContext.set(MessageDiffProbeContext(sessionClient, server.port, sessionId, environment.repoRoot))
-
- val files = linkedMapOf(
- "pkg/alpha.py" to "def value():\n return \"alpha-old\"\n",
- "pkg/bravo.py" to "def value():\n return \"bravo-old\"\n",
- "pkg/charlie.py" to "def value():\n return \"charlie-old\"\n",
- )
- files.forEach { (relativePath, content) ->
- val path = environment.repoRoot.resolve(relativePath)
- path.parent.createDirectories()
- path.writeText(content)
- }
-
- submitPromptAndAwaitTurn(
- sessionClient = sessionClient,
- events = events,
- port = server.port,
- sessionId = sessionId,
- turnTimeoutMs = 60_000,
- text = """
- Edit only these Python files: `pkg/alpha.py`, `pkg/bravo.py`, and `pkg/charlie.py`.
- Make exactly these replacements:
- - In `pkg/alpha.py`, replace `alpha-old` with `alpha-new`.
- - In `pkg/bravo.py`, replace `bravo-old` with `bravo-new`.
- - In `pkg/charlie.py`, replace `charlie-old` with `charlie-new`.
- Do not modify any other files.
- """.trimIndent(),
- )
-
- val expectedFiles = files.keys.toSet()
- assertFileText(environment.repoRoot.resolve("pkg/alpha.py"), "def value():\n return \"alpha-new\"\n")
- assertFileText(environment.repoRoot.resolve("pkg/bravo.py"), "def value():\n return \"bravo-new\"\n")
- assertFileText(environment.repoRoot.resolve("pkg/charlie.py"), "def value():\n return \"charlie-new\"\n")
-
- val finalDiff = assertIs>(
- sessionClient.fetchSessionDiffSnapshot(server.port, sessionId),
- ).value
- val finalFiles = finalDiff.files.map { normalizeDiffPath(environment.repoRoot, it.file) }.toSet()
- assertEquals(
- expectedFiles,
- finalFiles.intersect(expectedFiles),
- "final server diff should include all Python files"
- )
-
- val latestLoadedFiles = snapshots
- .groupBy { it.messageId }
- .values
- .flatMap { it.last().fetchedFiles }
- .map { normalizeDiffPath(environment.repoRoot, it) }
- .filter { it in expectedFiles }
- .toSet()
-
- assertEquals(
- expectedFiles,
- latestLoadedFiles,
- buildString {
- appendLine("latest message-diff fetches should include every Python file")
- appendLine("messageDiffEvents=${events.messageDiffEvents(sessionId)}")
- appendLine("snapshots=${snapshots.toList()}")
- appendLine("finalFiles=$finalFiles")
- },
- )
- }
- }
-
- @Test
- fun `reverted AI changes are absent from restored file list`() {
- if (!version.startsWith("1.16.")) return
-
- withLiveSession(version) { environment, server, sessionClient, events, sessionId ->
- val relativePath = "revert-me.txt"
- val original = "Original content\n"
- val aiContent = "AI content\n"
- val file = environment.repoRoot.resolve(relativePath)
- file.writeText(original)
-
- submitPromptAndAwaitTurn(
- sessionClient = sessionClient,
- events = events,
- port = server.port,
- sessionId = sessionId,
- text = """
- Edit only `$relativePath`.
- Replace the entire file content with exactly:
- ```text
- AI content
- ```
- Keep the trailing newline.
- Do not modify any other files.
- """.trimIndent(),
- )
- assertFileText(file, aiContent)
-
- val serverDiff = assertIs>(
- sessionClient.fetchSessionDiffSnapshot(server.port, sessionId),
- ).value
- assertTrue(
- serverDiff.files.any { normalizeDiffPath(environment.repoRoot, it.file) == relativePath },
- "server message history should still report $relativePath after the AI edit",
- )
-
- file.writeText(original)
- assertFileText(file, original)
-
- val harness = DiffPipelineHarness(
- projectBase = environment.repoRoot.toString(),
- sessionId = sessionId,
- )
- harness.disk[harness.abs(relativePath)] = original
- harness.applyHistoricalSessionDiffFiles(serverDiff.files)
-
- assertEquals(
- 0,
- harness.trackedFileCount(),
- "reverted AI changes should not remain visible in the restored session file list",
- )
}
}
private fun withLiveSession(
version: String,
allowTask: Boolean = false,
- onEvent: (OpenCodeEvent) -> Unit = {},
block: (
environment: OpenCodeTestEnvironment,
server: OpenCodeTestServer,
@@ -488,7 +185,7 @@ class OpenCodeDiffLiveTest(
OpenCodeTestEnvironmentFactory.create(version, allowTask = allowTask).use { environment ->
val server = environment.startServer()
val sessionClient = SessionApiClient()
- OpenCodeTestEventCollector(server.port, environment.repoRoot.toString(), onEvent).use { events ->
+ OpenCodeTestEventCollector(server.port, environment.repoRoot.toString()).use { events ->
try {
events.awaitConnected()
val session = assertIs>(
@@ -496,7 +193,6 @@ class OpenCodeDiffLiveTest(
).value
block(environment, server, sessionClient, events, session.sessionId)
} catch (t: Throwable) {
- environment.preserveForDiagnostics()
t.addSuppressed(
IllegalStateException(
buildString {
@@ -555,9 +251,8 @@ class OpenCodeDiffLiveTest(
rootSessionId: String,
repoRoot: Path,
expectedRelativePaths: Set,
- timeoutMs: Long,
): ChildDiffs {
- val deadline = System.currentTimeMillis() + timeoutMs
+ val deadline = System.currentTimeMillis() + 30_000
var lastSessions: List = emptyList()
var lastDiffsBySessionId: Map = emptyMap()
@@ -598,8 +293,6 @@ class OpenCodeDiffLiveTest(
diffSummaryRoleByFile.keys == expectedRelativePaths
) {
return ChildDiffs(
- sessions = sessions,
- diffsBySessionId = diffsBySessionId.filterValues { diff -> diff.files.isNotEmpty() },
fileToChildSessionId = fileToChildSessionId,
diffSummaryRoleByFile = diffSummaryRoleByFile,
)
@@ -641,131 +334,10 @@ class OpenCodeDiffLiveTest(
}.getOrDefault(file.replace('\\', '/'))
}
- private fun applyRealDiffsToCoreState(
- projectBase: String,
- rootSessionId: String,
- sessions: List,
- diffsBySessionId: Map,
- ): CoreDiffStateHarness.VisibleState {
- val harness = CoreDiffStateHarness(projectBase)
- diffsBySessionId.forEach { (diffSessionId, diff) ->
- harness.applyLiveMessageDiff(
- sessionId = diffSessionId,
- diff = diff,
- readContent = { absPath -> Path.of(absPath).takeIf { it.exists() }?.readText() ?: "" },
- )
- }
-
- return harness.selectRootAndVisibleState(rootSessionId, sessions)
- }
-
private fun assertFileText(path: Path, expected: String) {
val actual = if (path.exists()) normalizeNewlinesOnly(path.readText()) else ""
assertEquals(normalizeNewlinesOnly(expected), actual, "Unexpected file content at $path")
}
- private fun awaitFileDiffPreview(
- sessionClient: SessionApiClient,
- port: Int,
- sessionId: String,
- projectBase: String,
- absFilePath: String,
- timeoutMs: Long = 20_000,
- ): SessionApiClient.FileDiffPreview {
- val deadline = System.currentTimeMillis() + timeoutMs
- while (System.currentTimeMillis() < deadline) {
- val result = sessionClient.fetchFileDiffPreview(port, sessionId, projectBase, absFilePath)
- val preview = (result as? ApiResult.Success)?.value
- if (preview != null) {
- return preview
- }
- Thread.sleep(100)
- }
-
- throw AssertionError(
- "Timed out waiting for 3-panel preview for $absFilePath",
- )
- }
-
- private fun assertInlineDiffFromServerPayload(
- repoRoot: String,
- sessionId: String,
- diffFile: SessionDiffFile,
- expectedRemoved: String,
- expectedAdded: String,
- ) {
- val relativeFile = diffFile.file
- val harness = DiffPipelineHarness(
- projectBase = repoRoot,
- sessionId = sessionId,
- hunkComputer = { fileDiff, sid ->
- if (fileDiff.before == fileDiff.after) emptyList()
- else listOf(
- DiffHunk(
- filePath = fileDiff.file,
- startLine = sharedPrefixLineCount(fileDiff.before, fileDiff.after),
- removedLines = contentLines(fileDiff.before).drop(
- sharedPrefixLineCount(fileDiff.before, fileDiff.after)
- ).dropLast(sharedSuffixLineCount(fileDiff.before, fileDiff.after)),
- addedLines = contentLines(fileDiff.after).drop(
- sharedPrefixLineCount(fileDiff.before, fileDiff.after)
- ).dropLast(sharedSuffixLineCount(fileDiff.before, fileDiff.after)),
- sessionId = sid,
- )
- )
- },
- )
- harness.disk[harness.abs(relativeFile)] = diffFile.after
- harness.applySessionDiffFiles(listOf(diffFile))
-
- val hunks = harness.hunksFor(relativeFile)
- assertTrue(hunks.isNotEmpty(), "inline diff should produce hunks for $relativeFile")
- val removedText = normalizeNewlinesOnly(hunks.flatMap { it.removedLines }.joinToString("\n"))
- val addedText = normalizeNewlinesOnly(hunks.flatMap { it.addedLines }.joinToString("\n"))
- assertEquals(normalizeNewlinesOnly(expectedRemoved), removedText)
- assertEquals(normalizeNewlinesOnly(expectedAdded), addedText)
- }
-
- private fun assertPreviewMatchesServerDiff(
- preview: SessionApiClient.FileDiffPreview,
- diffFile: SessionDiffFile,
- ) {
- assertEquals(normalizeNewlinesOnly(diffFile.before), normalizeNewlinesOnly(preview.before))
- assertEquals(normalizeNewlinesOnly(diffFile.after), normalizeNewlinesOnly(preview.after))
- }
-
- private fun lines(values: Iterable): String = values.joinToString(separator = "\n") + "\n"
-
- private fun sharedPrefixLineCount(before: String, after: String): Int {
- val beforeLines = contentLines(before)
- val afterLines = contentLines(after)
- var prefix = 0
- while (prefix < beforeLines.size && prefix < afterLines.size && beforeLines[prefix] == afterLines[prefix]) {
- prefix += 1
- }
- return prefix
- }
-
- private fun sharedSuffixLineCount(before: String, after: String): Int {
- val beforeLines = contentLines(before)
- val afterLines = contentLines(after)
- val prefix = sharedPrefixLineCount(before, after)
- var beforeEndExclusive = beforeLines.size
- var afterEndExclusive = afterLines.size
- var suffix = 0
- while (
- beforeEndExclusive > prefix &&
- afterEndExclusive > prefix &&
- beforeLines[beforeEndExclusive - 1] == afterLines[afterEndExclusive - 1]
- ) {
- beforeEndExclusive -= 1
- afterEndExclusive -= 1
- suffix += 1
- }
- return suffix
- }
-
- private fun contentLines(content: String): List = if (content.isEmpty()) emptyList() else content.lines()
-
private fun normalizeNewlinesOnly(content: String): String = content.replace("\r\n", "\n")
}
diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeConnectionAndSessionLiveTest.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeConnectionAndSessionLiveTest.kt
deleted file mode 100644
index 12babce..0000000
--- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeConnectionAndSessionLiveTest.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package com.ashotn.opencode.relay.integration.session
-
-import com.ashotn.opencode.relay.api.session.SessionApiClient
-import com.ashotn.opencode.relay.integration.OpenCodeTestEnvironmentFactory
-import com.ashotn.opencode.relay.integration.OpenCodeTestVersions
-import com.ashotn.opencode.relay.api.transport.ApiResult
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
-import kotlin.test.assertIs
-import kotlin.test.assertTrue
-
-@RunWith(Parameterized::class)
-class OpenCodeConnectionAndSessionLiveTest(
- private val version: String,
-) {
-
- companion object {
- @JvmStatic
- @Parameterized.Parameters(name = "{0}")
- fun versions(): List> = OpenCodeTestVersions.all().map { arrayOf(it) }
- }
-
- @Test
- fun `creates real session against isolated server`() {
- val environment = OpenCodeTestEnvironmentFactory.create(version)
-
- try {
- val server = environment.startServer()
- val client = SessionApiClient()
-
- val result = client.createSession(server.port)
-
- val success = assertIs>(result)
- assertTrue(success.value.sessionId.isNotBlank())
- } finally {
- environment.close()
- }
- }
-}
diff --git a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt
index 39a9a7e..733b673 100644
--- a/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt
+++ b/src/liveTest/kotlin/com/ashotn/opencode/relay/integration/session/OpenCodeSessionStateLiveTest.kt
@@ -74,7 +74,6 @@ class OpenCodeSessionStateLiveTest(
timeoutMs = 30_000,
)
} catch (t: Throwable) {
- environment.preserveForDiagnostics()
t.addSuppressed(
IllegalStateException(
buildString {
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeRelayPromptPlugin.kt b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeRelayPromptPlugin.kt
index 972832a..f9589e9 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeRelayPromptPlugin.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/OpenCodeRelayPromptPlugin.kt
@@ -76,7 +76,7 @@ internal object OpenCodeRelayPromptPlugin {
internal fun ideGuidance(): String =
"""You are running inside ${ideDescriptionProvider()} through OpenCode Relay (plugin version ${pluginVersionProvider()}).
-When you would normally cite a file, use clickable local paths. Prefer visible bare paths for long or deeply nested files because terminal wrapping can break Markdown link targets. Good forms: path/to/File.kt, ./path/to/File.kt, path/to/File.kt#L42, ./path/to/File.kt#L42-L48. Markdown links are acceptable for short targets: [File.kt:42](./path/to/File.kt#L42). Avoid using full paths, prefer relative paths.
+When citing files, emit plain project-relative paths using JetBrains terminal link syntax. Use ./path/to/File.kt:42 for a specific line and ./path/to/File.kt without one. For a range, link the first line and describe the range, for example ./path/to/File.kt:42 (lines 42-48). For a file outside the project, use a file:///absolute/path URL. Always include the ./ prefix for project-relative paths; do not use Markdown links, #L anchors, line-range suffixes, or plain absolute paths.
"""
private fun currentIdeDescription(): String {
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/SendProjectViewSelectionAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/SendProjectViewSelectionAction.kt
index 1475855..b2a8149 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/actions/SendProjectViewSelectionAction.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/SendProjectViewSelectionAction.kt
@@ -42,7 +42,7 @@ class SendProjectViewSelectionAction : AnAction(), DumbAware {
val projectBase = project.basePath
val ref = selected.joinToString(" ") { item ->
val relativePath = projectBase?.let { item.path.toProjectRelativePath(it) } ?: item.path
- "@$relativePath"
+ formatProjectViewReference(relativePath, item.isDirectory)
} + " "
OpenCodeTuiClient.getInstance(project).appendToTuiPrompt(ref) { success, error ->
@@ -94,3 +94,8 @@ class SendProjectViewSelectionAction : AnAction(), DumbAware {
else -> "${selected.size} selected item references"
}
}
+
+internal fun formatProjectViewReference(path: String, isDirectory: Boolean): String {
+ if (!isDirectory) return "@$path"
+ return "@${path.trimEnd('/').ifEmpty { "." }}/"
+}
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/actions/SendSelectionAction.kt b/src/main/kotlin/com/ashotn/opencode/relay/actions/SendSelectionAction.kt
index 8078afe..a5b40b1 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/actions/SendSelectionAction.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/actions/SendSelectionAction.kt
@@ -16,7 +16,7 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.DumbAware
/**
- * Editor action that appends an @path#Lstart or @path#Lstart-Lend file reference to the TUI's prompt
+ * Editor action that appends an @path#Lstart or @path#Lstart-end file reference to the TUI's prompt
* input buffer via POST /tui/append-prompt.
*
* Sends a reference only — no file content is embedded in the prompt. OpenCode resolves
@@ -70,7 +70,7 @@ class SendSelectionAction : AnAction(), DumbAware {
project.basePath?.let { base -> path.toProjectRelativePath(base) } ?: path
} ?: return
- val lineAnchor = if (startLine == endLine) "#L$startLine" else "#L$startLine-L$endLine"
+ val lineAnchor = if (startLine == endLine) "#L$startLine" else "#L$startLine-$endLine"
val ref = "@$relativePath$lineAnchor "
OpenCodeTuiClient.getInstance(project).appendToTuiPrompt(ref) { success, error ->
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt
index c421f98..27e0caa 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettings.kt
@@ -1,5 +1,6 @@
package com.ashotn.opencode.relay.settings
+import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.components.*
import com.intellij.openapi.project.Project
@@ -26,7 +27,7 @@ class OpenCodeSettings : PersistentStateComponent {
/** JBTerminalWidget (classic terminal plugin, works on all supported IDE versions). */
CLASSIC,
- /** Parked reworked terminal option kept for easy re-enable later. */
+ /** TerminalToolWindowTabsManager (reworked terminal, requires IntelliJ 2026.2+). */
REWORKED,
}
@@ -198,3 +199,18 @@ fun OpenCodeSettings.State.toSnapshot(): OpenCodeSettingsSnapshot = OpenCodeSett
fun OpenCodeSettings.processEnvironmentVariables(overrides: Map = emptyMap()): Map =
serverEnvironmentVariables.associate { it.name to it.value } + overrides
+
+internal const val REWORKED_TERMINAL_MIN_BASELINE_VERSION: Int = 262
+
+internal fun isReworkedTerminalSupported(
+ baselineVersion: Int = ApplicationInfo.getInstance().build.baselineVersion,
+): Boolean = baselineVersion >= REWORKED_TERMINAL_MIN_BASELINE_VERSION
+
+internal fun OpenCodeSettings.TerminalEngine.effectiveForIde(
+ baselineVersion: Int = ApplicationInfo.getInstance().build.baselineVersion,
+): OpenCodeSettings.TerminalEngine =
+ if (this == OpenCodeSettings.TerminalEngine.REWORKED && !isReworkedTerminalSupported(baselineVersion)) {
+ OpenCodeSettings.TerminalEngine.CLASSIC
+ } else {
+ this
+ }
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt
index e72751c..dcde632 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/settings/OpenCodeSettingsConfigurable.kt
@@ -225,9 +225,14 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
}
buttonsGroup("Terminal engine:") {
row {
- radioButton("Classic (Recommended)", TerminalEngine.CLASSIC)
+ radioButton("Classic", TerminalEngine.CLASSIC)
.comment("Legacy JediTerm widget.")
}
+ row {
+ radioButton("Reworked", TerminalEngine.REWORKED)
+ .comment("New terminal engine. Requires IntelliJ Platform 2026.2 or newer.")
+ .enabled(isReworkedTerminalSupported())
+ }
}.bind(pendingState::terminalEngine)
}
group("Diagnostics") {
@@ -419,8 +424,7 @@ class OpenCodeSettingsConfigurable(private val project: Project) :
pendingState.diffTraceHistoryEnabled = settings.diffTraceHistoryEnabled
pendingState.inlineTerminalEnabled = settings.inlineTerminalEnabled
pendingState.sessionsSectionVisible = settings.sessionsSectionVisible
- pendingState.terminalEngine =
- if (settings.terminalEngine == TerminalEngine.REWORKED) TerminalEngine.CLASSIC else settings.terminalEngine
+ pendingState.terminalEngine = settings.terminalEngine.effectiveForIde()
pendingState.braveModeEnabled = settings.braveModeEnabled
pendingState.jetBrainsMcpWarningEnabled = settings.jetBrainsMcpWarningEnabled
}
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTerminalHyperlinkFilter.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTerminalHyperlinkFilter.kt
new file mode 100644
index 0000000..3971a30
--- /dev/null
+++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTerminalHyperlinkFilter.kt
@@ -0,0 +1,64 @@
+package com.ashotn.opencode.relay.terminal
+
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.diagnostic.Logger
+import com.intellij.openapi.fileEditor.OpenFileDescriptor
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.terminal.JBTerminalPanel
+import com.jediterm.terminal.model.hyperlinks.HyperlinkFilter
+import com.jediterm.terminal.model.hyperlinks.LinkInfo
+import com.jediterm.terminal.model.hyperlinks.LinkResult
+import com.jediterm.terminal.model.hyperlinks.LinkResultItem
+import com.jediterm.terminal.model.hyperlinks.TextProcessing
+
+// Classic's embedded JediTerm does not receive Reworked Terminal's native file filters.
+private val logger = Logger.getInstance(ClassicTerminalHyperlinkFilter::class.java)
+
+internal fun installClassicTerminalHyperlinkFilter(project: Project, panel: JBTerminalPanel) {
+ val textProcessing = try {
+ panel.terminalTextProcessing()
+ } catch (error: ReflectiveOperationException) {
+ logger.warn("Failed to install Classic terminal hyperlink filter", error)
+ return
+ }
+ if (textProcessing == null) {
+ logger.warn("Skipping Classic terminal hyperlink filter because JediTerm text processing is unavailable")
+ return
+ }
+ textProcessing.addHyperlinkFilter(ClassicTerminalHyperlinkFilter(project))
+}
+
+private fun JBTerminalPanel.terminalTextProcessing(): TextProcessing? {
+ val method = terminalTextBuffer.javaClass.methods.firstOrNull { method ->
+ method.parameterCount == 0 && TextProcessing::class.java.isAssignableFrom(method.returnType)
+ } ?: return null
+ method.isAccessible = true
+ return method.invoke(terminalTextBuffer) as? TextProcessing
+}
+
+internal class ClassicTerminalHyperlinkFilter(
+ private val project: Project,
+ private val navigate: (VirtualFile, Int?) -> Unit = { virtualFile, lineNumber ->
+ ApplicationManager.getApplication().invokeLater {
+ if (lineNumber != null) {
+ OpenFileDescriptor(project, virtualFile, lineNumber - 1, 0).navigate(true)
+ } else {
+ OpenFileDescriptor(project, virtualFile).navigate(true)
+ }
+ }
+ },
+) : HyperlinkFilter {
+ private val localFileReferenceMatcher = LocalFileReferenceMatcher(project)
+
+ override fun apply(line: String): LinkResult? {
+ val links = localFileReferenceMatcher.findAll(line).map { target ->
+ LinkResultItem(
+ target.sourceStartOffset,
+ target.sourceEndOffset,
+ LinkInfo { navigate(target.virtualFile, target.lineNumberOneBased) },
+ )
+ }
+ return links.takeIf { it.isNotEmpty() }?.let(::LinkResult)
+ }
+}
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 b6f01ca..6f5fed9 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanel.kt
@@ -17,7 +17,6 @@ import com.intellij.terminal.JBTerminalPanel
import com.intellij.terminal.ui.TerminalWidget
import com.intellij.util.ui.ImageUtil
import com.jediterm.terminal.TtyConnector
-import com.pty4j.PtyProcess
import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner
import org.jetbrains.plugins.terminal.ShellStartupOptions
import org.jetbrains.plugins.terminal.ShellTerminalWidget
@@ -35,10 +34,7 @@ import java.awt.image.BufferedImage
import java.io.File
import java.io.IOException
import java.lang.reflect.Proxy
-import java.lang.invoke.MethodHandles
-import java.lang.invoke.MethodType
import java.nio.file.Files
-import java.util.concurrent.ExecutionException
import javax.imageio.ImageIO
import javax.swing.ImageIcon
import javax.swing.JPanel
@@ -61,6 +57,7 @@ class ClassicTuiPanel(
private var terminalWidget: TerminalWidget? = null
private var terminalPanel: JBTerminalPanel? = null
+ private var hyperlinkMouseGuard: Disposable? = null
init {
Disposer.register(parentDisposable, this)
@@ -116,6 +113,7 @@ class ClassicTuiPanel(
ShellTerminalWidget.asShellJediTermWidget(widget)?.terminalPanel
?.also { panel ->
installEmbeddedTerminalDataProvider(project, panel)
+ hyperlinkMouseGuard = installTerminalHyperlinkMouseGuard(panel)
installFileDropTarget(panel)
installClipboardFilePasteHandler(panel)
}
@@ -175,6 +173,8 @@ class ClassicTuiPanel(
override fun dispose() = tearDown()
private fun uninstallEmbeddedTerminalIntegrations() {
+ hyperlinkMouseGuard?.let { Disposer.dispose(it) }
+ hyperlinkMouseGuard = null
terminalPanel?.let { panel ->
uninstallEmbeddedTerminalDataProvider(panel)
panel.dropTarget = null
@@ -313,41 +313,8 @@ class ClassicTuiPanel(
}
private class ClipboardAwareTerminalRunner(project: Project) : LocalTerminalDirectRunner(project) {
- override fun createTtyConnector(process: PtyProcess): TtyConnector =
- wrapTtyConnector(super.createTtyConnector(process))
-
- /**
- * Runtime override for newer IDEs where terminal startup calls createTtyConnector(ShellStartupOptions).
- * This project still compiles against 2024.3, where that method is absent, so this cannot use `override`.
- */
- @Suppress("unused")
- @Throws(ExecutionException::class)
- fun createTtyConnector(startupOptions: ShellStartupOptions): TtyConnector =
- wrapTtyConnector(createPlatformTtyConnector(startupOptions))
-
- private fun createPlatformTtyConnector(startupOptions: ShellStartupOptions): TtyConnector {
- val createTtyConnectorHandle = try {
- val methodType = MethodType.methodType(TtyConnector::class.java, ShellStartupOptions::class.java)
- MethodHandles.lookup()
- .findSpecial(
- LocalTerminalDirectRunner::class.java,
- "createTtyConnector",
- methodType,
- ClipboardAwareTerminalRunner::class.java,
- )
- .bindTo(this)
- } catch (_: NoSuchMethodException) {
- return super.createTtyConnector(createProcess(startupOptions))
- } catch (e: IllegalAccessException) {
- logger.warn("Unable to call new terminal connector path; falling back to PTY connector", e)
- return super.createTtyConnector(createProcess(startupOptions))
- } catch (e: LinkageError) {
- logger.warn("Unable to link new terminal connector path; falling back to PTY connector", e)
- return super.createTtyConnector(createProcess(startupOptions))
- }
-
- return createTtyConnectorHandle.invokeWithArguments(startupOptions) as TtyConnector
- }
+ override fun createTtyConnector(startupOptions: ShellStartupOptions): TtyConnector =
+ wrapTtyConnector(super.createTtyConnector(startupOptions))
private fun wrapTtyConnector(delegate: TtyConnector): TtyConnector =
Osc52ClipboardTtyConnector(delegate) { text ->
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/LocalFileReferenceMatcher.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/LocalFileReferenceMatcher.kt
new file mode 100644
index 0000000..806b87c
--- /dev/null
+++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/LocalFileReferenceMatcher.kt
@@ -0,0 +1,103 @@
+package com.ashotn.opencode.relay.terminal
+
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.LocalFileSystem
+import com.intellij.openapi.vfs.VirtualFile
+import java.io.File
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+
+internal class LocalFileReferenceMatcher(project: Project) {
+ private val projectBaseDirectory = project.basePath?.let(LocalFileSystem.getInstance()::findFileByPath)
+
+ fun findAll(line: String): List {
+ if ('/' !in line) return emptyList()
+
+ return localFileReferenceStartRegex.findAll(line).mapNotNull { match ->
+ val sourceStartOffset = match.range.first
+ val sourceEndOffset = findReferenceEnd(line, match.range.last + 1)
+ resolve(line.substring(sourceStartOffset, sourceEndOffset), sourceStartOffset)
+ }.toList()
+ }
+
+ private fun resolve(rawTarget: String, sourceStartOffset: Int): ResolvedLocalFileReference? {
+ var target = rawTarget
+ while (true) {
+ resolveExactTarget(target)?.let { (virtualFile, lineNumber) ->
+ return ResolvedLocalFileReference(
+ virtualFile,
+ virtualFile.path,
+ lineNumber,
+ sourceStartOffset,
+ sourceStartOffset + target.length,
+ )
+ }
+ if (target.lastOrNull() !in trailingReferencePunctuation) return null
+ target = target.dropLast(1)
+ }
+ }
+
+ private fun resolveExactTarget(target: String): Pair? {
+ val (path, lineNumber) = parseLineSuffix(target)
+ if (path.contains("://") || path.endsWith("/.")) return null
+
+ val decodedPath = try {
+ URLDecoder.decode(path.replace("+", "%2B"), StandardCharsets.UTF_8)
+ } catch (_: IllegalArgumentException) {
+ path
+ }
+ val file = File(decodedPath)
+ val virtualFile = if (file.isAbsolute) {
+ LocalFileSystem.getInstance().findFileByIoFile(file)
+ } else {
+ projectBaseDirectory?.findFileByRelativePath(decodedPath)
+ }
+ return virtualFile?.let { it to lineNumber }
+ }
+}
+
+internal data class ResolvedLocalFileReference(
+ val virtualFile: VirtualFile,
+ val path: String,
+ val lineNumberOneBased: Int?,
+ val sourceStartOffset: Int,
+ val sourceEndOffset: Int,
+)
+
+private fun parseLineSuffix(target: String): Pair {
+ val separator = target.lastIndexOf(':')
+ val lineNumber = if (separator >= 0) target.substring(separator + 1).toIntOrNull() else null
+ return if (lineNumber != null && lineNumber > 0) target.substring(0, separator) to lineNumber else target to null
+}
+
+private fun findReferenceEnd(line: String, contentStart: Int): Int {
+ var index = contentStart
+ while (index < line.length) {
+ index = when (line[index]) {
+ '[', '(' -> findRouteSegmentEnd(line, index) ?: return index
+ ']', ')', '<', '>', '"', '|' -> return index
+ else -> if (line[index].isWhitespace()) return index else index + 1
+ }
+ }
+ return index
+}
+
+private fun findRouteSegmentEnd(line: String, start: Int): Int? {
+ val closingToken = when {
+ line.startsWith("[[", start) -> "]]"
+ line[start] == '[' -> "]"
+ else -> ")"
+ }
+ val contentStart = start + closingToken.length
+ var index = contentStart
+ while (index < line.length) {
+ if (line.startsWith(closingToken, index)) return (index + closingToken.length).takeIf { index > contentStart }
+ if (line[index].isWhitespace() || line[index] in routeSegmentDelimiters) return null
+ index++
+ }
+ return null
+}
+
+private val localFileReferenceStartRegex = Regex("""(?', '"', '|')
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/MarkdownTerminalHyperlinkFilter.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/MarkdownTerminalHyperlinkFilter.kt
deleted file mode 100644
index 337f57d..0000000
--- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/MarkdownTerminalHyperlinkFilter.kt
+++ /dev/null
@@ -1,189 +0,0 @@
-package com.ashotn.opencode.relay.terminal
-
-import com.intellij.openapi.application.ApplicationManager
-import com.intellij.openapi.diagnostic.Logger
-import com.intellij.openapi.fileEditor.OpenFileDescriptor
-import com.intellij.openapi.project.Project
-import com.intellij.openapi.vfs.LocalFileSystem
-import com.intellij.openapi.vfs.VirtualFile
-import com.intellij.terminal.JBTerminalPanel
-import com.jediterm.terminal.model.hyperlinks.HyperlinkFilter
-import com.jediterm.terminal.model.hyperlinks.LinkInfo
-import com.jediterm.terminal.model.hyperlinks.LinkResult
-import com.jediterm.terminal.model.hyperlinks.LinkResultItem
-import com.jediterm.terminal.model.hyperlinks.TextProcessing
-import java.io.File
-import java.net.URI
-
-// Adds clickable local file links in embedded JediTerm output. Supported examples:
-// - Markdown links: [File.kt](./src/File.kt), [File.kt:42](src/main/File.kt#L42)
-// - Bare paths: ./src/File.kt, ../src/File.kt, /abs/path/File.kt, src/main/File.kt
-// - Line anchors: ./src/File.kt#L42, src/main/File.kt#L42-L48, note.md#L2
-// - Line suffixes: ./src/File.kt:42, src/main/File.kt:42-48, /abs/path/File.kt:42
-// Candidates only become links after resolving to real local files; URI-like targets are ignored.
-private val markdownLinkRegex = Regex("""\[([^\]\r\n]+)]\(([^)\s]+)\)""")
-private val lineAnchorReferenceRegex =
- Regex("""(?"]+?#L\d+(?:-L\d+)?)(?!(?:[\w/-]|\.\w))""")
-private val lineSuffixReferenceRegex =
- Regex("""(?"]+?:\d+(?:-\d+)?)(?!(?:[\w/-]|\.\w))""")
-private val localFileReferenceRegex = Regex("""(?"]+)(?![\w./-])""")
-private val lineAnchorRegex = Regex("""^(.*)#L(\d+)(?:-L\d+)?$""")
-private val lineSuffixRegex = Regex("""^(.*):(\d+)(?:-\d+)?$""")
-private val trailingReferencePunctuation = setOf('.', ',', ':', ';')
-private val logger = Logger.getInstance(MarkdownTerminalHyperlinkFilter::class.java)
-
-internal fun installMarkdownTerminalHyperlinkFilter(project: Project, panel: JBTerminalPanel) {
- runCatching {
- val textProcessing = panel.terminalTextProcessing()
- if (textProcessing == null) {
- logger.warn("Skipping Markdown terminal hyperlink filter because JediTerm text processing is unavailable")
- return@runCatching
- }
- textProcessing.addHyperlinkFilter(MarkdownTerminalHyperlinkFilter(project))
- }.onFailure { error ->
- logger.warn("Failed to install Markdown terminal hyperlink filter", error)
- }
-}
-
-private fun JBTerminalPanel.terminalTextProcessing(): TextProcessing? {
- val method = terminalTextBuffer.javaClass.methods.firstOrNull { method ->
- method.parameterCount == 0 && TextProcessing::class.java.isAssignableFrom(method.returnType)
- } ?: return null
- method.isAccessible = true
- return method.invoke(terminalTextBuffer) as? TextProcessing
-}
-
-internal class MarkdownTerminalHyperlinkFilter(
- private val project: Project,
- private val navigate: (VirtualFile, Int?) -> Unit = { virtualFile, lineNumber ->
- ApplicationManager.getApplication().invokeLater {
- if (lineNumber != null) {
- OpenFileDescriptor(project, virtualFile, lineNumber - 1, 0).navigate(true)
- } else {
- OpenFileDescriptor(project, virtualFile).navigate(true)
- }
- }
- },
-) : HyperlinkFilter {
- override fun apply(line: String): LinkResult? {
- val hasMarkdownCandidate = '[' in line
- val hasLineAnchorCandidate = "#L" in line
- val hasLocalFileCandidate = '/' in line
- val hasLineSuffixCandidate = ':' in line
- if (!hasMarkdownCandidate && !hasLineAnchorCandidate && !hasLocalFileCandidate && !hasLineSuffixCandidate) return null
-
- val items = mutableListOf()
- val consumedRanges = mutableListOf()
-
- fun createLinkForMatch(
- matchRange: IntRange,
- target: String,
- trimTrailingPunctuation: Boolean = false,
- ): LinkResultItem? {
- val linkRange =
- if (trimTrailingPunctuation) trimTrailingReferencePunctuation(matchRange, target) else matchRange
- val linkTarget = if (trimTrailingPunctuation) target.take(linkRange.last - matchRange.first + 1) else target
-
- if (consumedRanges.any { range ->
- rangesOverlap(
- linkRange.first,
- linkRange.last + 1,
- range.first,
- range.last + 1
- )
- }) {
- return null
- }
-
- val resolvedTarget = resolveTerminalLinkTarget(linkTarget) ?: return null
- consumedRanges.add(linkRange)
- return createLinkResultItem(linkRange.first, linkRange.last + 1, resolvedTarget)
- }
-
- if (hasMarkdownCandidate) {
- markdownLinkRegex.findAll(line).mapNotNullTo(items) { match ->
- createLinkForMatch(match.range, match.groupValues[2])
- }
- }
-
- if (hasLineAnchorCandidate) {
- lineAnchorReferenceRegex.findAll(line).mapNotNullTo(items) { match ->
- createLinkForMatch(match.range, match.groupValues[1])
- }
- }
-
- if (hasLineSuffixCandidate) {
- lineSuffixReferenceRegex.findAll(line).mapNotNullTo(items) { match ->
- createLinkForMatch(match.range, match.groupValues[1])
- }
- }
-
- if (hasLocalFileCandidate) {
- localFileReferenceRegex.findAll(line).mapNotNullTo(items) { match ->
- createLinkForMatch(match.range, match.groupValues[1], trimTrailingPunctuation = true)
- }
- }
-
- return items.takeIf { it.isNotEmpty() }?.let(::LinkResult)
- }
-
- private fun createLinkResultItem(startOffset: Int, endOffset: Int, target: ResolvedTerminalLink): LinkResultItem =
- LinkResultItem(startOffset, endOffset, LinkInfo { navigate(target.virtualFile, target.lineNumber) })
-
- private fun resolveTerminalLinkTarget(target: String): ResolvedTerminalLink? {
- val parsedTarget = parseLineAnchor(target)
- // Exclude URIs
- if (parsedTarget.path.contains("://")) return null
-
- val basePath = project.basePath ?: return null
- val file = File(parsedTarget.path).let { path ->
- if (path.isAbsolute) path else File(basePath, decodePath(parsedTarget.path))
- }
-
- // This filter runs while JediTerm holds its text-buffer lock, so avoid any filesystem stat here.
- // `findFileByIoFile` uses the VFS cache; files become linkable after normal VFS refreshes.
- return LocalFileSystem.getInstance().findFileByIoFile(file)
- ?.takeUnless { it.isDirectory }
- ?.let { ResolvedTerminalLink(it, parsedTarget.lineNumber) }
- }
-
- private fun parseLineAnchor(target: String): ParsedTerminalLinkTarget {
- val anchor = lineAnchorRegex.matchEntire(target)
- return if (anchor != null) {
- ParsedTerminalLinkTarget(anchor.groupValues[1], anchor.groupValues[2].toIntOrNull()?.coerceAtLeast(1))
- } else {
- val suffix = lineSuffixRegex.matchEntire(target)
- if (suffix != null) {
- ParsedTerminalLinkTarget(suffix.groupValues[1], suffix.groupValues[2].toIntOrNull()?.coerceAtLeast(1))
- } else {
- ParsedTerminalLinkTarget(target, null)
- }
- }
- }
-
- private fun rangesOverlap(start: Int, end: Int, otherStart: Int, otherEnd: Int): Boolean =
- start < otherEnd && otherStart < end
-
- private fun trimTrailingReferencePunctuation(matchRange: IntRange, target: String): IntRange {
- var end = matchRange.last
- var targetIndex = target.lastIndex
- while (targetIndex >= 0 && target[targetIndex] in trailingReferencePunctuation) {
- end--
- targetIndex--
- }
- return matchRange.first..end
- }
-
- private fun decodePath(path: String): String =
- runCatching { URI(null, null, path, null).path ?: path }.getOrDefault(path)
-}
-
-private data class ParsedTerminalLinkTarget(
- val path: String,
- val lineNumber: Int?,
-)
-
-private data class ResolvedTerminalLink(
- val virtualFile: VirtualFile,
- val lineNumber: Int?,
-)
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/OpenCodeFileMentionFilterProvider.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/OpenCodeFileMentionFilterProvider.kt
new file mode 100644
index 0000000..5ae4f90
--- /dev/null
+++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/OpenCodeFileMentionFilterProvider.kt
@@ -0,0 +1,79 @@
+package com.ashotn.opencode.relay.terminal
+
+import com.intellij.execution.filters.AbstractFileHyperlinkFilter
+import com.intellij.execution.filters.ConsoleFilterProvider
+import com.intellij.execution.filters.FileHyperlinkRawData
+import com.intellij.execution.filters.Filter
+import com.intellij.openapi.project.DumbAware
+import com.intellij.openapi.project.Project
+import java.io.File
+
+/** Makes OpenCode's local file references and structured @ mentions navigable. */
+class OpenCodeFileMentionFilterProvider : ConsoleFilterProvider {
+ override fun getDefaultFilters(project: Project): Array =
+ arrayOf(createOpenCodeFileMentionFilter(project))
+}
+
+internal fun createOpenCodeFileMentionFilter(project: Project): Filter =
+ OpenCodeFileMentionFilter(project)
+
+private class OpenCodeFileMentionFilter(project: Project) :
+ AbstractFileHyperlinkFilter(project, project.basePath),
+ DumbAware {
+ private val projectBasePath = project.basePath
+ private val localFileReferenceMatcher = LocalFileReferenceMatcher(project)
+
+ override fun parse(line: String): List {
+ if (line.length > MAX_LINE_LENGTH) return emptyList()
+
+ val links = mutableListOf()
+ if ('@' in line) {
+ fileMentionRegex.findAll(line).forEach { match ->
+ val (path, lineText) = match.destructured
+ val lineNumber = lineText.toIntOrNull() ?: return@forEach
+ links += createLinkData(path, lineNumber - 1, match.range)
+ }
+ directoryMentionRegex.findAll(line).forEach { match ->
+ val (path) = match.destructured
+ links += createLinkData(path, -1, match.range)
+ }
+ }
+
+ localFileReferenceMatcher.findAll(line).forEach { target ->
+ links += FileHyperlinkRawData(
+ target.path,
+ target.lineNumberOneBased?.minus(1) ?: -1,
+ -1,
+ target.sourceStartOffset,
+ target.sourceEndOffset,
+ )
+ }
+ return links
+ .distinctBy { it.hyperlinkStartInd to it.hyperlinkEndInd }
+ .sortedBy { it.hyperlinkStartInd }
+ }
+
+ private fun createLinkData(path: String, lineNumber: Int, range: IntRange): FileHyperlinkRawData {
+ val resolvedPath = File(path).let { file ->
+ if (file.isAbsolute || projectBasePath == null) path else File(projectBasePath, path).path
+ }
+ return FileHyperlinkRawData(
+ resolvedPath,
+ lineNumber,
+ -1,
+ range.first,
+ range.last + 1,
+ )
+ }
+}
+
+private val fileMentionRegex = Regex(
+ """(? Unit) {
+ scope.coroutineContext[Job]?.invokeOnCompletion {
+ if (!disposed.get()) listener()
+ }
+ }
+
+ override fun dispose() {
+ if (!disposed.compareAndSet(false, true)) return
+ try {
+ Disposer.dispose(listenerDisposable)
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ companion object {
+ private const val SESSIONS_MANAGER_CLASS = "com.intellij.terminal.frontend.session.TerminalSessionsManager"
+ private const val CONNECTOR_LISTENER_CLASS = "com.intellij.terminal.frontend.session.TtyConnectorListener"
+ private val logger = Logger.getInstance(ReworkedOsc52Session::class.java)
+
+ fun tryStart(
+ project: Project,
+ startupOptions: ShellStartupOptions,
+ tabBuilder: Any,
+ clipboardWriter: (String) -> Unit,
+ ): ReworkedOsc52Session? {
+ val listenerDisposable = Disposer.newDisposable("OpenCode reworked terminal OSC52 listener")
+ val scope = CoroutineScope(
+ SupervisorJob() + Dispatchers.Default + ClientId.localId.asContextElement()
+ )
+ val session = ReworkedOsc52Session(scope, listenerDisposable)
+
+ return try {
+ val classLoader = ReworkedOsc52Session::class.java.classLoader
+ val managerClass = Class.forName(SESSIONS_MANAGER_CLASS, true, classLoader)
+ val listenerClass = Class.forName(CONNECTOR_LISTENER_CLASS, true, classLoader)
+ val manager = managerClass
+ .getMethod("getInstance", Project::class.java)
+ .invoke(null, project)
+ val startResult = managerClass
+ .getMethod("startSession", ShellStartupOptions::class.java, CoroutineScope::class.java)
+ .invoke(manager, startupOptions, scope)
+ val sessionId = startResult.javaClass.getMethod("getSessionId").invoke(startResult)
+ val connector = startResult.javaClass.getMethod("getTtyConnector").invoke(startResult)
+ val handler = Osc52ClipboardHandler(clipboardWriter)
+ val listener = Proxy.newProxyInstance(
+ listenerClass.classLoader,
+ arrayOf(listenerClass),
+ ) { proxy, method, args ->
+ when (method.name) {
+ "charsRead" -> {
+ val chars = args?.get(0) as CharArray
+ val offset = args[1] as Int
+ val length = args[2] as Int
+ handler.process(String(chars, offset, length))
+ null
+ }
+
+ "equals" -> proxy === args?.firstOrNull()
+ "hashCode" -> System.identityHashCode(proxy)
+ "toString" -> "OpenCode OSC52 terminal connector listener"
+ else -> null
+ }
+ }
+ connector.javaClass
+ .getMethod("addListener", Disposable::class.java, listenerClass)
+ .invoke(connector, listenerDisposable, listener)
+
+ val sessionIdMethod = tabBuilder.javaClass.declaredMethods.firstOrNull { method ->
+ method.name == "sessionId" && method.parameterCount == 1
+ } ?: error("Reworked terminal tab builder does not expose sessionId")
+ sessionIdMethod.isAccessible = true
+ sessionIdMethod.invoke(tabBuilder, sessionId)
+ session
+ } catch (failure: Throwable) {
+ val cause = (failure as? InvocationTargetException)?.targetException ?: failure
+ try {
+ session.dispose()
+ } catch (cleanupFailure: Throwable) {
+ cause.addSuppressed(cleanupFailure)
+ rethrowCriticalFailure(cleanupFailure)
+ }
+
+ rethrowCriticalFailure(cause)
+
+ logger.warn("OSC52 interception is unavailable for this Reworked Terminal version", cause)
+ null
+ }
+ }
+ }
+}
+
+private fun rethrowCriticalFailure(failure: Throwable) {
+ when (failure) {
+ is CancellationException, is ControlFlowException -> throw failure
+ is InterruptedException -> {
+ Thread.currentThread().interrupt()
+ throw failure
+ }
+
+ is Error -> if (failure !is LinkageError) throw failure
+ }
+}
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt
index 2533d1a..9df34ed 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/ReworkedTuiPanel.kt
@@ -8,37 +8,37 @@ import com.ashotn.opencode.relay.settings.OpenCodeSettings
import com.ashotn.opencode.relay.settings.OpenCodeServerAuth
import com.ashotn.opencode.relay.settings.processEnvironmentVariables
import com.ashotn.opencode.relay.util.serverUrl
+import com.intellij.ide.dnd.DnDSupport
+import com.intellij.ide.dnd.FileCopyPasteUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.diagnostic.ControlFlowException
+import com.intellij.openapi.diagnostic.Logger
+import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
-import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTab
+import com.intellij.platform.ide.progress.runWithModalProgressBlocking
import com.intellij.terminal.frontend.toolwindow.TerminalToolWindowTabsManager
import com.intellij.terminal.frontend.view.TerminalView
import com.intellij.terminal.frontend.view.TerminalViewSessionState
-import com.intellij.ui.content.Content
-import com.intellij.openapi.diagnostic.Logger
+import com.jediterm.core.util.TermSize
import kotlinx.coroutines.launch
+import org.jetbrains.plugins.terminal.ShellStartupOptions
import java.awt.BorderLayout
+import java.util.concurrent.CancellationException
+import java.util.concurrent.atomic.AtomicReference
import javax.swing.JPanel
/**
* Hosts an embedded Reworked Terminal running `opencode attach `.
*
- * Uses the official [TerminalToolWindowTabsManager] API (available since 2025.3)
- * to create a terminal session that is never shown in the Terminal tool window —
- * `shouldAddToToolWindow(false)` keeps it fully detached so it lives only inside
- * this panel. The [TerminalView.component] is embedded directly in the panel's
- * [BorderLayout.CENTER].
+ * Uses the experimental [TerminalToolWindowTabsManager] API to create a detached terminal
+ * session that is never shown or persisted in the native Terminal tool window. Correct
+ * detached-session persistence requires IntelliJ Platform 2026.2 or newer. The
+ * [TerminalView.component] is embedded in the panel's [BorderLayout.CENTER].
*
* The terminal is started lazily on the first call to [startIfNeeded] and lives
* for as long as this panel's parent [Disposable] is alive.
- *
- * **Testability note:** unlike [ClassicTuiPanel], this panel cannot be unit-tested
- * with a stub process. It delegates process cleanup entirely to the platform's
- * [Content] disposal chain — there is no explicit kill path to inject into or
- * assert against. [TerminalToolWindowTabsManager] also requires a fully initialised
- * IDE frontend that is not available in a headless test environment.
*/
class ReworkedTuiPanel(
private val project: Project,
@@ -47,9 +47,10 @@ class ReworkedTuiPanel(
private val onTerminated: (() -> Unit)? = null,
) : JPanel(BorderLayout()), TuiPanel, Disposable {
- private var terminalTab: TerminalToolWindowTab? = null
- private var terminalContent: Content? = null
private var terminalView: TerminalView? = null
+ private var fileDropDisposable: Disposable? = null
+ private var hyperlinkMouseGuard: Disposable? = null
+ private var osc52Session: ReworkedOsc52Session? = null
init {
Disposer.register(parentDisposable, this)
@@ -86,27 +87,66 @@ class ReworkedTuiPanel(
)
val manager = TerminalToolWindowTabsManager.getInstance(project)
- // shouldAddToToolWindow(false): create the session entirely detached —
- // it never appears as a tab in the Terminal tool window.
- val tab = manager.createTabBuilder()
+ val tabBuilder = manager.createTabBuilder()
.workingDirectory(workingDir)
.requestFocus(false)
- .shouldAddToToolWindow(false)
.tabName("OpenCode Relay")
.shellCommand(command)
- .createTab()
+ val startupOptions = ShellStartupOptions.Builder()
+ .workingDirectory(workingDir)
+ .shellCommand(command)
+ .initialTermSize(TermSize(80, 20))
+ .build()
+ val pendingOsc52Session = AtomicReference()
+ try {
+ runWithModalProgressBlocking(project, "Starting OpenCode terminal") {
+ pendingOsc52Session.set(
+ ReworkedOsc52Session.tryStart(project, startupOptions, tabBuilder) { text ->
+ ApplicationManager.getApplication().invokeLater {
+ if (!project.isDisposed) CopyPasteManager.copyTextToClipboard(text)
+ }
+ }
+ )
+ }
+ osc52Session = pendingOsc52Session.getAndSet(null)
+ } finally {
+ pendingOsc52Session.getAndSet(null)?.let { Disposer.dispose(it) }
+ }
+ osc52Session?.let { session ->
+ session.invokeOnTermination {
+ ApplicationManager.getApplication().invokeLater {
+ if (
+ osc52Session === session &&
+ terminalView?.sessionState?.value != TerminalViewSessionState.Running
+ ) {
+ tearDown()
+ onTerminated?.invoke()
+ }
+ }
+ }
+ }
+ if (osc52Session != null) {
+ // An injected session is already running and must be connected immediately.
+ // The fixed initial size is updated by the view after it is embedded below.
+ tabBuilder.deferSessionStartUntilUiShown(false)
+ }
+ val tab = tabBuilder.createTab()
- val view = tab.view
- val content = tab.content
- terminalTab = tab
- terminalContent = content
+ // Notify the backend that this directly-created tab is externally owned so it
+ // is excluded from native Terminal persistence.
+ var detached = false
+ val view = try {
+ manager.detachTab(tab).also {
+ detached = true
+ }
+ } finally {
+ if (!detached) {
+ Disposer.dispose(tab.content as Disposable)
+ }
+ }
terminalView = view
-
- // shouldAddToToolWindow(false) means the content is never added to a ContentManager,
- // so closeTab() would be a no-op (it routes through ContentManager.removeContent).
- // Register the content in our own disposable tree so it is properly disposed when
- // this panel is disposed, and so Disposer does not flag it as leaked under ROOT_DISPOSABLE.
- Disposer.register(this, content as Disposable)
+ hyperlinkMouseGuard = installTerminalHyperlinkMouseGuard(view.component)
+ installFileDropTarget(view)
// Watch sessionState flow: when Terminated the shell has exited.
view.coroutineScope.launch {
@@ -115,12 +155,7 @@ class ReworkedTuiPanel(
if (state is TerminalViewSessionState.Terminated) {
ApplicationManager.getApplication().invokeLater {
if (terminalView === view) {
- terminalView = null
- terminalTab = null
- terminalContent = null
- remove(view.component)
- revalidate()
- repaint()
+ tearDown()
onTerminated?.invoke()
}
}
@@ -132,12 +167,22 @@ class ReworkedTuiPanel(
revalidate()
repaint()
- } catch (e: NoClassDefFoundError) {
+ } catch (e: LinkageError) {
+ tearDown()
logger.warn("Reworked terminal classes unavailable", e)
// Panel stays empty.
} catch (e: Exception) {
+ tearDown()
+ if (e is CancellationException || e is ControlFlowException) throw e
+ if (e is InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw e
+ }
logger.warn("Failed to start reworked terminal", e)
// Panel stays empty.
+ } catch (e: Throwable) {
+ tearDown()
+ throw e
}
}
@@ -152,24 +197,59 @@ class ReworkedTuiPanel(
/** Tears down the running session. The next [startIfNeeded] will create a fresh one. */
override fun stop() = tearDown()
+ private fun installFileDropTarget(view: TerminalView) {
+ val disposable = Disposer.newDisposable("OpenCode reworked terminal file drop")
+ fileDropDisposable = disposable
+ DnDSupport.createBuilder(view.component)
+ .disableAsSource()
+ .enableAsNativeTarget()
+ .setDropHandlerWithResult { event ->
+ val files = FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject)
+ if (files.isEmpty()) return@setDropHandlerWithResult false
+
+ view.preferredFocusableComponent.requestFocusInWindow()
+ view.coroutineScope.launch {
+ files.forEach { file ->
+ view.createSendTextBuilder()
+ .useBracketedPasteMode()
+ .send(file.absolutePath)
+ }
+ }
+ true
+ }
+ .setDisposableParent(disposable)
+ .install()
+ }
+
private fun tearDown() {
- val view = terminalView ?: return
- val content = terminalContent
+ val view = terminalView
terminalView = null
- terminalTab = null
- terminalContent = null
- remove(view.component)
- revalidate()
- repaint()
- // Dispose the content to shut down the shell and release all associated resources.
- // We can't use closeTab() here because it delegates to ContentManager.removeContent,
- // which does nothing when the content has no manager (our case, since we used
- // shouldAddToToolWindow(false) and never added the content to a ContentManager).
- if (content != null) {
- Disposer.dispose(content as Disposable)
- } else {
- // Fallback in case we lost the content reference — cancel the coroutine scope directly.
- view.coroutineScope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
+ val dropTarget = fileDropDisposable
+ fileDropDisposable = null
+ val mouseGuard = hyperlinkMouseGuard
+ hyperlinkMouseGuard = null
+ val session = osc52Session
+ osc52Session = null
+
+ try {
+ try {
+ try {
+ mouseGuard?.let { Disposer.dispose(it) }
+ } finally {
+ dropTarget?.let { Disposer.dispose(it) }
+ }
+ } finally {
+ session?.let { Disposer.dispose(it) }
+ }
+ } finally {
+ if (view != null) {
+ // Detaching transfers ownership from the Terminal tool window to this panel.
+ // Cancel the view scope to terminate the process and release the frontend session.
+ view.coroutineScope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
+ remove(view.component)
+ revalidate()
+ repaint()
+ }
}
}
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 7818bba..ff927fe 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProviders.kt
@@ -21,7 +21,7 @@ internal fun installEmbeddedTerminalDataProvider(
// explicit null wins before any ancestor ToolWindow provider in the data-context chain.
installTerminalToolWindowOverride(panel)
installEmbeddedTerminalKeyOverrides(panel)
- installMarkdownTerminalHyperlinkFilter(project, panel)
+ installClassicTerminalHyperlinkFilter(project, panel)
}
internal fun uninstallEmbeddedTerminalDataProvider(panel: JBTerminalPanel) {
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalHyperlinkMouseGuard.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalHyperlinkMouseGuard.kt
new file mode 100644
index 0000000..7833179
--- /dev/null
+++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TerminalHyperlinkMouseGuard.kt
@@ -0,0 +1,45 @@
+package com.ashotn.opencode.relay.terminal
+
+import com.intellij.ide.IdeEventQueue
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.util.Disposer
+import java.awt.Component
+import java.awt.Cursor
+import java.awt.event.MouseEvent
+import javax.swing.JComponent
+import javax.swing.SwingUtilities
+
+/** Prevents terminal mouse reporting from receiving the press/release used to follow a hyperlink. */
+internal fun installTerminalHyperlinkMouseGuard(root: JComponent): Disposable {
+ val disposable = Disposer.newDisposable("OpenCode terminal hyperlink mouse guard")
+ IdeEventQueue.getInstance().addDispatcher(
+ TerminalHyperlinkMouseGuard(root),
+ disposable,
+ )
+ return disposable
+}
+
+internal class TerminalHyperlinkMouseGuard(
+ private val root: JComponent,
+) : IdeEventQueue.NonLockedEventDispatcher {
+ private var guardedPress = false
+
+ override fun dispatch(e: java.awt.AWTEvent): Boolean {
+ val mouseEvent = e as? MouseEvent ?: return false
+ val source = mouseEvent.component ?: return false
+ if (!source.isIn(root) || mouseEvent.button != MouseEvent.BUTTON1) return false
+
+ return when (mouseEvent.id) {
+ MouseEvent.MOUSE_PRESSED -> {
+ guardedPress = source.cursor.type == Cursor.HAND_CURSOR
+ guardedPress
+ }
+
+ MouseEvent.MOUSE_RELEASED -> guardedPress.also { guardedPress = false }
+ else -> false
+ }
+ }
+}
+
+private fun Component.isIn(root: JComponent): Boolean =
+ this === root || SwingUtilities.isDescendingFrom(this, root)
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt
index 7ab3bfb..aa74576 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/terminal/TuiPanel.kt
@@ -7,11 +7,12 @@ import javax.swing.JPanel
* Common contract for the embeddable terminal panel that runs
* `opencode attach ` inside the tool window.
*
+ * Two implementations exist:
+ * - [ClassicTuiPanel] backed by the classic JediTerm widget
+ * - [ReworkedTuiPanel] backed by the TerminalToolWindowTabsManager API
+ *
* The active implementation is chosen by
* [com.ashotn.opencode.relay.settings.OpenCodeSettings.terminalEngine].
- *
- * The reworked implementation is currently parked and excluded from the build,
- * so the runtime always uses [ClassicTuiPanel].
*/
interface TuiPanel : Disposable {
/** The Swing component to embed in the tool window. */
diff --git a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt
index 311b86a..a6113c6 100644
--- a/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt
+++ b/src/main/kotlin/com/ashotn/opencode/relay/toolwindow/OpenCodeToolWindowPanel.kt
@@ -13,12 +13,17 @@ import com.ashotn.opencode.relay.permission.OpenCodePermissionService
import com.ashotn.opencode.relay.settings.OpenCodeSettings
import com.ashotn.opencode.relay.settings.OpenCodeSettings.TerminalEngine
import com.ashotn.opencode.relay.settings.OpenCodeSettingsChangedListener
+import com.ashotn.opencode.relay.settings.effectiveForIde
import com.ashotn.opencode.relay.terminal.ClassicTuiPanel
+import com.ashotn.opencode.relay.terminal.ReworkedTuiPanel
import com.ashotn.opencode.relay.terminal.TuiPanel
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
+import com.intellij.openapi.wm.ToolWindow
+import com.intellij.openapi.wm.ToolWindowManager
+import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
@@ -35,6 +40,7 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou
companion object {
private const val CARD_CONTENT = "content"
private const val CARD_PENDING = "pending"
+ private const val TOOL_WINDOW_ID = "OpenCode Relay"
/** Installs a theme-adaptive, 1px divider on [pane] so it matches the IDE border color. */
fun applyThemedDivider(pane: JSplitPane) {
@@ -58,12 +64,12 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou
private val mcpWarningPanel = JetBrainsMcpWarningPanel(project)
private val pendingFilesPanel = PendingFilesPanel(project, this)
private var tuiPanel: TuiPanel = createTuiPanel()
- private var activeTuiEngine: TerminalEngine =
- effectiveTerminalEngine(OpenCodeSettings.getInstance(project).terminalEngine)
+ private var activeTuiEngine: TerminalEngine = configuredTuiEngine()
private val syncScheduled = AtomicBoolean(false)
private val plugin = OpenCodePlugin.getInstance(project)
private val serverStateListener = ServerStateListener { requestSyncCard() }
private var expandedDividerLocation: Int? = null
+ private var disposed = false
// Split pane that stacks content (top) and TUI (bottom).
// The TUI half is hidden until the server is READY.
@@ -108,6 +114,15 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou
PermissionChangedListener { requestSyncCard() }
)
+ project.messageBus.connect(this).subscribe(
+ ToolWindowManagerListener.TOPIC,
+ object : ToolWindowManagerListener {
+ override fun toolWindowShown(toolWindow: ToolWindow) {
+ if (toolWindow.id == TOOL_WINDOW_ID) requestSyncCard()
+ }
+ }
+ )
+
plugin.addListener(serverStateListener)
project.messageBus.connect(this).subscribe(
@@ -133,10 +148,11 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou
}
private fun requestSyncCard() {
+ if (disposed || project.isDisposed) return
if (!syncScheduled.compareAndSet(false, true)) return
ApplicationManager.getApplication().invokeLater {
syncScheduled.set(false)
- syncCard()
+ if (!disposed && !project.isDisposed) syncCard()
}
}
@@ -144,9 +160,15 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou
val settings = OpenCodeSettings.getInstance(project)
val serverReady = plugin.serverState == ServerState.READY
val inlineTerminal = serverReady && settings.inlineTerminalEnabled
+ val toolWindowVisible = ToolWindowManager.getInstance(project)
+ .getToolWindow(TOOL_WINDOW_ID)
+ ?.isVisible == true
- if (inlineTerminal) {
+ if (inlineTerminal && !tuiPanel.isStarted && toolWindowVisible) {
tuiPanel.startIfNeeded()
+ }
+
+ if (inlineTerminal) {
if (tuiPanel.isStarted) {
if (!settings.sessionsSectionVisible) {
if (splitPane.dividerSize > 0) {
@@ -212,7 +234,7 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou
* it into the split pane — all without requiring an IDE restart.
*/
private fun swapTuiPanelIfEngineChanged() {
- val configuredEngine = effectiveTerminalEngine(OpenCodeSettings.getInstance(project).terminalEngine)
+ val configuredEngine = configuredTuiEngine()
if (configuredEngine == activeTuiEngine) return
// Stop and dispose the old panel.
@@ -244,18 +266,15 @@ class OpenCodeToolWindowPanel(private val project: Project) : JPanel(BorderLayou
repaint()
}
- /**
- * Creates the terminal panel for the currently configured [OpenCodeSettings.terminalEngine].
- * The reworked implementation is parked and currently resolved to [ClassicTuiPanel].
- */
+ /** Creates the terminal panel for the configured [OpenCodeSettings.terminalEngine]. */
private fun createTuiPanel(): TuiPanel =
- ClassicTuiPanel(project, this, onTerminated = { requestSyncCard() })
-
- private fun effectiveTerminalEngine(requested: TerminalEngine): TerminalEngine =
- if (requested == TerminalEngine.REWORKED) TerminalEngine.CLASSIC else requested
-
+ when (configuredTuiEngine()) {
+ TerminalEngine.CLASSIC -> ClassicTuiPanel(project, this, onTerminated = { requestSyncCard() })
+ TerminalEngine.REWORKED -> ReworkedTuiPanel(project, this, onTerminated = { requestSyncCard() })
+ }
- private var disposed = false
+ private fun configuredTuiEngine(): TerminalEngine =
+ OpenCodeSettings.getInstance(project).terminalEngine.effectiveForIde()
override fun dispose() {
if (disposed) return
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index 61efedf..9385b8a 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -10,7 +10,7 @@
]]>
com.intellij.modules.platform
- org.jetbrains.plugins.terminal
+ org.jetbrains.plugins.terminalcom.intellij.mcpServer
@@ -28,6 +28,13 @@
/>
+
+
+
+
+
diff --git a/src/main/resources/META-INF/withTerminal.xml b/src/main/resources/META-INF/withTerminal.xml
deleted file mode 100644
index d3f237f..0000000
--- a/src/main/resources/META-INF/withTerminal.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js b/src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js
index 3305e59..4e15070 100644
--- a/src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js
+++ b/src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js
@@ -3,7 +3,11 @@ const IDE_GUIDANCE = __OPENCODE_RELAY_IDE_GUIDANCE__
export const OpenCodeRelayPromptPlugin = async () => ({
"experimental.chat.system.transform": async (_input, output) => {
if (!Array.isArray(output.system)) return
- if (!output.system.includes(IDE_GUIDANCE)) output.system.push(IDE_GUIDANCE)
+ if (output.system.some((message) => typeof message === "string" && message.includes(IDE_GUIDANCE))) return
+
+ output.system[0] = output.system[0]
+ ? `${output.system[0]}\n\n${IDE_GUIDANCE}`
+ : IDE_GUIDANCE
},
})
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeRelayPromptPluginTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeRelayPromptPluginTest.kt
index f966572..f2be53c 100644
--- a/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeRelayPromptPluginTest.kt
+++ b/src/test/kotlin/com/ashotn/opencode/relay/OpenCodeRelayPromptPluginTest.kt
@@ -106,10 +106,11 @@ class OpenCodeRelayPromptPluginTest {
val pluginText = Files.readString(configDirectory.resolve("plugins/opencode-relay-prompt.js"))
val guidance = extractIdeGuidance(pluginText)
assertTrue(guidance.contains("You are running inside IntelliJ IDEA 2026.1 (build IU-261.1) through OpenCode Relay (plugin version test-plugin-version)."))
- assertTrue(guidance.contains("Prefer visible bare paths for long or deeply nested files"))
- assertTrue(guidance.contains("path/to/File.kt#L42"))
- assertTrue(guidance.contains("./path/to/File.kt#L42-L48"))
- assertTrue(guidance.contains("[File.kt:42](./path/to/File.kt#L42)"))
+ assertTrue(guidance.contains("./path/to/File.kt:42"))
+ assertTrue(guidance.contains("./path/to/File.kt without one"))
+ assertTrue(guidance.contains("./path/to/File.kt:42 (lines 42-48)"))
+ assertTrue(guidance.contains("file:///absolute/path"))
+ assertTrue(guidance.contains("do not use Markdown links, #L anchors, line-range suffixes, or plain absolute paths"))
assertFalse(pluginText.contains("__OPENCODE_RELAY_IDE_GUIDANCE__"))
}
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/actions/SendProjectViewSelectionActionTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/actions/SendProjectViewSelectionActionTest.kt
new file mode 100644
index 0000000..47f6101
--- /dev/null
+++ b/src/test/kotlin/com/ashotn/opencode/relay/actions/SendProjectViewSelectionActionTest.kt
@@ -0,0 +1,18 @@
+package com.ashotn.opencode.relay.actions
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SendProjectViewSelectionActionTest {
+ @Test
+ fun `mixed selection formatting appends slash only to directories`() {
+ val references = listOf(
+ formatProjectViewReference("src/main/App.kt", isDirectory = false),
+ formatProjectViewReference("src/main/kotlin", isDirectory = true),
+ ).joinToString(" ")
+
+ assertEquals("@src/main/App.kt @src/main/kotlin/", references)
+ assertEquals("@./", formatProjectViewReference("", isDirectory = true))
+ assertEquals("@src/", formatProjectViewReference("src/", isDirectory = true))
+ }
+}
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransportTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransportTest.kt
index 572166a..506ba0b 100644
--- a/src/test/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransportTest.kt
+++ b/src/test/kotlin/com/ashotn/opencode/relay/api/transport/OpenCodeHttpTransportTest.kt
@@ -148,26 +148,6 @@ class OpenCodeHttpTransportTest {
assertTrue(error.message.isNotBlank())
}
- @Test
- fun `parseJsonObjectResponse unwraps successful object body`() {
- val transport = OpenCodeHttpTransport()
-
- val result = transport.parseJsonObjectResponse(ApiResult.Success("{\"id\":\"ses_1\"}"))
-
- val success = assertIs>(result)
- assertEquals("ses_1", success.value.get("id").asString)
- }
-
- @Test
- fun `parseJsonArrayResponse fails on non-array body`() {
- val transport = OpenCodeHttpTransport()
-
- val result = transport.parseJsonArrayResponse(ApiResult.Success("{\"id\":\"ses_1\"}"))
-
- val failure = assertIs(result)
- assertIs(failure.error)
- }
-
@Test
fun `mapJsonObjectResponse maps object payload`() {
val transport = OpenCodeHttpTransport()
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/core/MessageSummaryFileCountLoadPlannerTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/core/MessageSummaryFileCountLoadPlannerTest.kt
index f604307..18a271a 100644
--- a/src/test/kotlin/com/ashotn/opencode/relay/core/MessageSummaryFileCountLoadPlannerTest.kt
+++ b/src/test/kotlin/com/ashotn/opencode/relay/core/MessageSummaryFileCountLoadPlannerTest.kt
@@ -11,10 +11,9 @@ class MessageSummaryFileCountLoadPlannerTest {
@Test
fun `message summary file count loads only newest eligible summary sessions up to batch size`() {
val sessions = listOf(
- session("old", updated = 10, summarized = true),
session("newest", updated = 50, summarized = true),
session("plain", updated = 100, summarized = false),
- session("loaded", updated = 40, summarized = true),
+ session("loaded", updated = 60, summarized = true),
session("middle", updated = 30, summarized = true),
session("newer", updated = 45, summarized = true),
)
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt
index b949679..dcad491 100644
--- a/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt
+++ b/src/test/kotlin/com/ashotn/opencode/relay/core/SessionDiffPipelineTest.kt
@@ -172,35 +172,6 @@ class SessionDiffPipelineTest {
assertTrue(h.addedFiles().isEmpty(), "reverted file should be removed from addedFiles")
}
- // -------------------------------------------------------------------------
- // Historical reloads compare the server-provided baseline to current disk.
- // If the user already reverted the file to that baseline before restore,
- // the file should not be restored as an empty tracked entry.
- // -------------------------------------------------------------------------
- @Test
- fun `historical baseline matching file is removed from restored state`() {
- val file = "note.md"
- val original = "Original content\n"
- val aiContent = "AI content\n"
-
- h.disk[h.abs(file)] = original
- h.applyHistoricalSessionDiffFiles(
- listOf(
- SessionDiffFile(
- file = h.abs(file),
- before = original,
- after = aiContent,
- additions = 1,
- deletions = 1,
- status = SessionDiffStatus.MODIFIED,
- )
- )
- )
-
- assertEquals(0, h.trackedFileCount(), "historical baseline match should not be restored")
- assertTrue(h.hunkFiles().isEmpty(), "historical baseline match should not create empty hunk entry")
- }
-
// -------------------------------------------------------------------------
// If the AI adds content in one turn and removes it in a later AI turn, the
// later live diff is computed against the start of that AI turn. It must
@@ -461,31 +432,6 @@ class SessionDiffPipelineTest {
)
}
- // -------------------------------------------------------------------------
- // Editor inline rendering asks QueryService for live hunks. When the root
- // session is selected, live hunks from child/sub-agent sessions must be
- // returned as part of the selected root family.
- // -------------------------------------------------------------------------
- @Test
- fun `root session inline hunk lookup includes child session live hunks`() {
- val file = "/project/live-subagents/alpha.txt"
- val hunk = DiffHunk(
- filePath = file,
- startLine = 0,
- removedLines = emptyList(),
- addedLines = listOf("alpha from sub-agent"),
- sessionId = "ses_child",
- )
-
- val hunks = QueryService().liveHunks(
- filePath = file,
- familySessionIds = { setOf("ses_root", "ses_child") },
- liveHunksBySessionAndFile = mapOf("ses_child" to mapOf(file to listOf(hunk))),
- )
-
- assertEquals(listOf(hunk), hunks)
- }
-
// -------------------------------------------------------------------------
// A sub-agent live diff can arrive before the session hierarchy refresh that
// tells the plugin child.parentID == root. While root is selected, that diff
@@ -602,114 +548,6 @@ class SessionDiffPipelineTest {
assertEquals("line1\n", h.baseline(file), "turn 2 baseline should be turn 1's final content")
}
- // -------------------------------------------------------------------------
- // When you double-click a file in the diff viewer after the AI has modified
- // it across multiple turns (e.g. add poem in turn 1, add signature in turn 2),
- // the diff must show all changes from the original file — not just the most
- // recent turn's change. The "before" side of the diff must always be the content
- // the file had before the AI touched it at all in this conversation.
- //
- // MANUAL VERIFICATION:
- // 1. Ask the AI to append a poem to a note file.
- // 2. In a second turn, ask the AI to sign the note with an author name.
- // 3. Double-click the file in the diff viewer.
- // 4. The "before" side must show the original file (no poem, no signature).
- // If it shows the file with the poem already present, this invariant is violated.
- // -------------------------------------------------------------------------
- @Test
- fun `diff preview before shows original content across multiple turns`() {
- val projectBase = "/project"
- val generation = 1L
- val file = "notes/note1.md"
- val absFile = "$projectBase/$file"
- val originalContent = "# Note\n\nOriginal content.\n"
- val afterPoem = "$originalContent\n## Poem\n\nRoses are red.\n"
- val afterSignature = "$afterPoem\n— Cipher Moonwhisper\n"
-
- val stateStore = StateStore()
- val stateLock = Any()
- val disk = mutableMapOf()
-
- val computer = SessionDiffApplyComputer(
- contentReader = { absPath -> disk[absPath] ?: "" },
- hunkComputer = { fileDiff, sid ->
- if (fileDiff.before == fileDiff.after) emptyList()
- else listOf(
- DiffHunk(
- fileDiff.file, 0,
- if (fileDiff.before.isEmpty()) emptyList() else listOf(fileDiff.before),
- if (fileDiff.after.isEmpty()) emptyList() else listOf(fileDiff.after),
- sid
- )
- )
- },
- log = NoOpLogger,
- tracer = NoOpDiffTracer,
- )
-
- // Simulate what the server returns for GET /session/{id}/diff:
- // it always carries the true original "before" for each file, regardless of
- // how many live turns have run. We model this with fromHistory=true, which
- // makes SessionDiffApplyComputer use diffFile.before directly.
- fun simulateServerDiffFetch(sessionId: String, serverBefore: String, currentContent: String) {
- disk[absFile] = currentContent
- val revision = stateStore.reserveRevisionForSessionDiffApply(
- stateLock = stateLock,
- sessionId = sessionId,
- expectedGeneration = generation,
- currentGeneration = { generation },
- )!!
- val event = SessionDiffSnapshot(
- sessionId = sessionId,
- files = listOf(
- SessionDiffFile(
- file = absFile,
- before = serverBefore, // server's authoritative original
- after = currentContent,
- additions = 1,
- deletions = 0,
- status = SessionDiffStatus.MODIFIED,
- )
- ),
- )
- val computedState = computer.compute(
- projectBase = projectBase,
- event = event,
- fromHistory = true,
- )
- stateStore.commitSessionDiffApply(
- stateLock = stateLock,
- sessionId = sessionId,
- revision = revision,
- fromHistory = true,
- computedState = computedState,
- nowMillis = 1000L,
- expectedGeneration = generation,
- currentGeneration = { generation },
- )
- }
-
- // The server is fetched for the most recently active session (sign).
- // It returns originalContent as "before" — the state before any AI edits.
- simulateServerDiffFetch(
- sessionId = "ses_sign",
- serverBefore = originalContent,
- currentContent = afterSignature,
- )
-
- // The baseline stored from the server fetch must be the original content,
- // not the intermediate post-poem content.
- val storedBefore = stateStore.baselineBeforeBySessionAndFile["ses_sign"]?.get(absFile)
-
- assertEquals(
- originalContent,
- storedBefore,
- "diff preview 'before' must be the server-provided original file content, " +
- "but got before.length=${storedBefore?.length} " +
- "(afterPoem.length=${afterPoem.length}, originalContent.length=${originalContent.length})",
- )
- }
-
private fun session(id: String, parentId: String? = null): Session = Session(
id = id,
projectID = null,
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/core/StateStoreResetTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/core/StateStoreResetTest.kt
new file mode 100644
index 0000000..ddde2ce
--- /dev/null
+++ b/src/test/kotlin/com/ashotn/opencode/relay/core/StateStoreResetTest.kt
@@ -0,0 +1,42 @@
+package com.ashotn.opencode.relay.core
+
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class StateStoreResetTest {
+
+ @Test
+ fun `resetState clears stored state and revision counters`() {
+ val store = StateStore()
+ val sessionId = "session"
+ val path = "/project/file.kt"
+ val stateLock = Any()
+ store.selectedSessionId = sessionId
+ store.busyBySession[sessionId] = true
+ store.updatedAtBySession[sessionId] = 1L
+ store.hunksBySessionAndFile[sessionId] = emptyMap()
+ store.liveHunksBySessionAndFile[sessionId] = emptyMap()
+ store.deletedBySession[sessionId] = setOf(path)
+ store.addedBySession[sessionId] = setOf(path)
+ store.baselineBeforeBySessionAndFile[sessionId] = mapOf(path to "before")
+ store.messageSummaryFileCountBySession[sessionId] = 1
+ store.messageSummaryFileCountUpdatedAtBySession[sessionId] = 1L
+ assertEquals(1L, store.reserveRevisionForSessionDiffApply(stateLock, sessionId, 1L) { 1L })
+
+ store.resetState()
+
+ assertNull(store.selectedSessionId)
+ assertTrue(store.busyBySession.isEmpty())
+ assertTrue(store.updatedAtBySession.isEmpty())
+ assertTrue(store.hunksBySessionAndFile.isEmpty())
+ assertTrue(store.liveHunksBySessionAndFile.isEmpty())
+ assertTrue(store.deletedBySession.isEmpty())
+ assertTrue(store.addedBySession.isEmpty())
+ assertTrue(store.baselineBeforeBySessionAndFile.isEmpty())
+ assertTrue(store.messageSummaryFileCountBySession.isEmpty())
+ assertTrue(store.messageSummaryFileCountUpdatedAtBySession.isEmpty())
+ assertEquals(1L, store.reserveRevisionForSessionDiffApply(stateLock, sessionId, 1L) { 1L })
+ }
+}
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/core/session/SessionScopeResolverTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/core/session/SessionScopeResolverTest.kt
index 5cd8eda..c7e4adb 100644
--- a/src/test/kotlin/com/ashotn/opencode/relay/core/session/SessionScopeResolverTest.kt
+++ b/src/test/kotlin/com/ashotn/opencode/relay/core/session/SessionScopeResolverTest.kt
@@ -2,7 +2,6 @@ package com.ashotn.opencode.relay.core.session
import com.ashotn.opencode.relay.api.session.Session
import com.ashotn.opencode.relay.api.session.SessionTime
-import com.ashotn.opencode.relay.core.DiffHunk
import org.junit.Test
import kotlin.test.assertEquals
@@ -10,32 +9,10 @@ class SessionScopeResolverTest {
private val resolver = SessionScopeResolver()
- // -------------------------------------------------------------------------
- // When two separate sessions each create a file, the modified-files list for
- // each session must show only the file(s) that session touched. Switching
- // from Session 1 to Session 2 must not carry Session 1's files into Session
- // 2's list, and vice versa. The lists must be independent regardless of how
- // recently each session was active.
- //
- // MANUAL VERIFICATION:
- // 1. In Session 1, ask the AI to create note1.md.
- // The modified-files list for Session 1 should show only note1.md.
- // 2. Create a new Session 2 and ask it to create note2.md.
- // The modified-files list for Session 2 should show only note2.md.
- // 3. Switch back to Session 1.
- // The list should show only note1.md — not both files.
- // 4. If both files appear in both sessions, this invariant is violated.
- // -------------------------------------------------------------------------
@Test
- fun `switching between unrelated sessions shows only each session's own files`() {
+ fun `selected root family excludes an unrelated root session`() {
val session1 = "ses_001"
val session2 = "ses_002"
- val file1 = "/project/note1.md"
- val file2 = "/project/note2.md"
-
- // Both sessions were recently active (within the 60-second recent window).
- val t0 = 1_000_000L
- val nowMillis = t0 + 5_000L // 5 seconds later — both sessions are "recent"
val knownSessionIds = setOf(session1, session2)
val sessions = mapOf(
@@ -46,7 +23,7 @@ class SessionScopeResolverTest {
parentID = null,
title = "Session 1",
version = null,
- time = SessionTime(0L, t0, null),
+ time = SessionTime(0L, 0L, null),
summary = null,
share = null
),
@@ -57,54 +34,27 @@ class SessionScopeResolverTest {
parentID = null,
title = "Session 2",
version = null,
- time = SessionTime(0L, t0 + 1_000L, null),
+ time = SessionTime(0L, 0L, null),
summary = null,
share = null
),
)
- val busyBySession = mapOf(session1 to false, session2 to false)
- val updatedAtBySession = mapOf(session1 to t0, session2 to t0 + 1_000L)
-
- // Each session has diff state for its own file only.
- val hunksBySessionAndFile = mapOf(
- session1 to mapOf(file1 to listOf(DiffHunk(file1, 0, emptyList(), listOf("note1 content"), session1))),
- session2 to mapOf(file2 to listOf(DiffHunk(file2, 0, emptyList(), listOf("note2 content"), session2))),
- )
- // When Session 1 is selected, only file1 should be in the family scope.
val familyForSession1 = resolver.familySessionIds(
selectedSessionId = session1,
sessions = sessions,
knownSessionIds = knownSessionIds,
- busyBySession = busyBySession,
- updatedAtBySession = updatedAtBySession,
- hunksBySessionAndFile = hunksBySessionAndFile,
- nowMillis = nowMillis,
+ busyBySession = emptyMap(),
+ updatedAtBySession = emptyMap(),
+ hunksBySessionAndFile = emptyMap(),
+ nowMillis = 0L,
)
assertEquals(
setOf(session1),
familyForSession1,
- "Session 1's family must contain only Session 1 — not Session 2 — " +
+ "Session 1's family must contain only Session 1 — not the unrelated Session 2 — " +
"but got: $familyForSession1",
)
-
- // When Session 2 is selected, only file2 should be in the family scope.
- val familyForSession2 = resolver.familySessionIds(
- selectedSessionId = session2,
- sessions = sessions,
- knownSessionIds = knownSessionIds,
- busyBySession = busyBySession,
- updatedAtBySession = updatedAtBySession,
- hunksBySessionAndFile = hunksBySessionAndFile,
- nowMillis = nowMillis,
- )
-
- assertEquals(
- setOf(session2),
- familyForSession2,
- "Session 2's family must contain only Session 2 — not Session 1 — " +
- "but got: $familyForSession2",
- )
}
-}
\ No newline at end of file
+}
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/ipc/PatchDiffTextParserTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/ipc/PatchDiffTextParserTest.kt
index 59e0692..a523972 100644
--- a/src/test/kotlin/com/ashotn/opencode/relay/ipc/PatchDiffTextParserTest.kt
+++ b/src/test/kotlin/com/ashotn/opencode/relay/ipc/PatchDiffTextParserTest.kt
@@ -6,23 +6,6 @@ import kotlin.test.assertEquals
class PatchDiffTextParserTest {
- @Test
- fun `reconstructs before and after text from patch diff`() {
- val obj = JsonParser.parseString(
- """
- {
- "file": "a.txt",
- "patch": "Index: a.txt\n===================================================================\n--- a.txt\t\n+++ a.txt\t\n@@ -1 +1 @@\n-old\n+new\n"
- }
- """.trimIndent()
- ).asJsonObject
-
- val diffText = PatchDiffTextParser.parse(obj)
-
- assertEquals("old\n", diffText.before)
- assertEquals("new\n", diffText.after)
- }
-
@Test
fun `preserves no newline at end of file marker`() {
val obj = JsonParser.parseString(
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/lifecycle/ResetConnectionTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/lifecycle/ResetConnectionTest.kt
deleted file mode 100644
index 6c25e64..0000000
--- a/src/test/kotlin/com/ashotn/opencode/relay/lifecycle/ResetConnectionTest.kt
+++ /dev/null
@@ -1,126 +0,0 @@
-package com.ashotn.opencode.relay.lifecycle
-
-import com.ashotn.opencode.relay.core.DiffPipelineHarness
-import com.ashotn.opencode.relay.core.StateStore
-import com.ashotn.opencode.relay.ipc.SessionDiffStatus
-import org.junit.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-/**
- * Verifies that [StateStore.resetState] — the core of the "Reset Connection"
- * action — clears all accumulated client-side state after real diff activity.
- *
- * The test populates the store via the full message-diff pipeline
- * to ensure resetState() is exercised against realistic data, not an empty store.
- */
-class ResetConnectionTest {
-
- // -------------------------------------------------------------------------
- // After populating real diff state across multiple sessions and then calling
- // resetState(), every collection in the store must be empty and all scalar
- // fields must be back to their initial values. Stale state left behind would
- // cause incorrect diff highlights or phantom sessions after reconnecting.
- //
- // MANUAL VERIFICATION:
- // 1. Let the AI modify files so that diff highlights appear in the editor.
- // 2. Click the "Reset OpenCode" button in the tool window toolbar.
- // 3. All diff highlights should disappear immediately, as if the plugin
- // had just started fresh.
- // -------------------------------------------------------------------------
- @Test
- fun `resetState clears all diff state after real pipeline activity`() {
- val h = DiffPipelineHarness()
-
- // Populate state: two files modified, one added
- h.disk[h.abs("src/Main.kt")] = "fun main() {}\n"
- h.disk[h.abs("src/Util.kt")] = "fun util() {}\n"
- h.disk[h.abs("src/New.kt")] = ""
-
- h.applySessionDiff(
- listOf(
- "src/Main.kt" to SessionDiffStatus.MODIFIED,
- "src/Util.kt" to SessionDiffStatus.MODIFIED,
- "src/New.kt" to SessionDiffStatus.ADDED,
- )
- )
-
- // Also set a selected session to verify scalar state is cleared.
- h.selectCurrentSession()
-
- // Pre-condition: store has real data
- assertTrue(h.hunkFiles().isNotEmpty(), "pre-condition: hunkFiles should be populated")
- assertEquals(h.sessionId, h.selectedSessionId(), "pre-condition: selectedSessionId should be set")
-
- // Act: reset (mirrors what stopListening() calls internally)
- h.resetState()
-
- // Assert: every field on the store matches a freshly constructed instance.
- // Uses reflection so that any new field added to StateStore is automatically
- // covered — no manual update to this assertion is ever needed.
- assertMatchesFreshStore(h.stateStoreForAssertions())
- }
-
- // -------------------------------------------------------------------------
- // After a reset, the pipeline must accept new state as if starting fresh.
- // A session diff applied after reset must be treated as a first-ever event —
- // no stale revision counters or baseline data should interfere.
- //
- // MANUAL VERIFICATION:
- // 1. Let the AI modify a file so highlights appear.
- // 2. Click "Reset OpenCode".
- // 3. Without restarting the server, let the AI modify the same file again.
- // 4. Only the new changes should be highlighted — no ghost highlights from
- // the pre-reset session.
- // -------------------------------------------------------------------------
- @Test
- fun `pipeline accepts new state cleanly after reset`() {
- val h = DiffPipelineHarness()
-
- // First round of activity
- h.disk[h.abs("note.md")] = "original\n"
- h.applySessionDiff(listOf("note.md" to SessionDiffStatus.MODIFIED))
- assertEquals(setOf(h.abs("note.md")), h.hunkFiles(), "pre-reset: file should be tracked")
-
- // Reset
- h.resetState()
- assertTrue(h.hunkFiles().isEmpty(), "post-reset: hunkFiles should be empty")
-
- // Second round of activity after reset — must work as if starting fresh
- h.disk[h.abs("note.md")] = "new content\n"
- val result = h.applySessionDiff(listOf("note.md" to SessionDiffStatus.MODIFIED))
-
- assertEquals(setOf(h.abs("note.md")), h.hunkFiles(), "post-reset activity: file should be tracked again")
- assertEquals(
- setOf(h.abs("note.md")),
- result?.changedFiles,
- "post-reset activity: changedFiles should reflect new state"
- )
- // The baseline after reset should be the empty string (file had no prior content as far
- // as the fresh pipeline is concerned), not the "original\n" content from before the reset.
- assertEquals(
- "",
- h.baseline("note.md"),
- "post-reset activity: baseline should reflect fresh-start content, not pre-reset data"
- )
- }
-}
-
-/**
- * Asserts that every field of [store] matches the corresponding field on a freshly
- * constructed [StateStore]. Uses reflection (including private fields) so new
- * fields are automatically covered without any changes to this helper or the test.
- */
-private fun assertMatchesFreshStore(store: Any) {
- val fresh = StateStore()
- val mismatches = StateStore::class.java.declaredFields.mapNotNull { field ->
- field.isAccessible = true
- val actual = field.get(store)
- val expected = field.get(fresh)
- if (actual != expected) " ${field.name}: expected $expected but was $actual" else null
- }
- assertTrue(
- mismatches.isEmpty(),
- "store should match a fresh StateStore after reset, but these fields differ:\n${mismatches.joinToString("\n")}"
- )
-}
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanelProcessLeakTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanelProcessLeakTest.kt
index e5e654b..3c0a6b3 100644
--- a/src/test/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanelProcessLeakTest.kt
+++ b/src/test/kotlin/com/ashotn/opencode/relay/terminal/ClassicTuiPanelProcessLeakTest.kt
@@ -42,36 +42,6 @@ class ClassicTuiPanelProcessLeakTest : BasePlatformTestCase() {
assertFalse("process must be dead after dispose() (pid=$pid)", isAlive(pid))
}
- fun `test reset cycle kills both the old and the new terminal process`() {
- val process1 = spawnSleepProcess()
- val pid1 = process1.pid()
- val process2 = spawnSleepProcess()
- val pid2 = process2.pid()
-
- // Use a fresh panel per cycle via processOverride on each startIfNeeded call.
- // First session: inject process1.
- val panel = ClassicTuiPanel(project, testRootDisposable, processOverride = process1)
- ApplicationManager.getApplication().invokeAndWait { panel.startIfNeeded() }
- assertTrue("pre cycle 1: process1 alive (pid=$pid1)", isAlive(pid1))
-
- panel.stop()
- waitForDeath(pid1)
- assertFalse("cycle 1: process1 must be dead (pid=$pid1)", isAlive(pid1))
-
- // Second session: swap in process2 via a new panel (panel.stop() cleared terminalWidget).
- val panel2 = ClassicTuiPanel(project, testRootDisposable, processOverride = process2)
- ApplicationManager.getApplication().invokeAndWait { panel2.startIfNeeded() }
- assertTrue("pre cycle 2: process2 alive (pid=$pid2)", isAlive(pid2))
-
- panel2.stop()
- waitForDeath(pid2)
- assertFalse("cycle 2: process2 must be dead (pid=$pid2)", isAlive(pid2))
-
- assertFalse("cycle 1 process must still be dead (pid=$pid1)", isAlive(pid1))
- }
-
- // ---- helpers ----
-
private fun spawnSleepProcess(): Process =
ProcessBuilder("sleep", "3600").redirectErrorStream(true).start()
diff --git a/src/test/kotlin/com/ashotn/opencode/relay/terminal/Osc52ClipboardHandlerTest.kt b/src/test/kotlin/com/ashotn/opencode/relay/terminal/Osc52ClipboardHandlerTest.kt
new file mode 100644
index 0000000..6bf799a
--- /dev/null
+++ b/src/test/kotlin/com/ashotn/opencode/relay/terminal/Osc52ClipboardHandlerTest.kt
@@ -0,0 +1,59 @@
+package com.ashotn.opencode.relay.terminal
+
+import java.util.Base64
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class Osc52ClipboardHandlerTest {
+ @Test
+ fun `decodes BEL ST and C1 ST terminated sequences`() {
+ val clipboardWrites = mutableListOf()
+ val handler = Osc52ClipboardHandler(clipboardWrites::add)
+
+ handler.process(sequence("BEL", "\u0007"))
+ handler.process(sequence("ST", "\u001b\\"))
+ handler.process(sequence("C1 ST", "\u009c"))
+
+ assertEquals(listOf("BEL", "ST", "C1 ST"), clipboardWrites)
+ }
+
+ @Test
+ fun `handles sequences split across connector reads`() {
+ val clipboardWrites = mutableListOf()
+ val handler = Osc52ClipboardHandler(clipboardWrites::add)
+
+ sequence("split clipboard", "\u001b\\").forEach { char ->
+ handler.process(char.toString())
+ }
+
+ assertEquals(listOf("split clipboard"), clipboardWrites)
+ }
+
+ @Test
+ fun `handles multiple sequences in one connector read`() {
+ val clipboardWrites = mutableListOf()
+ val handler = Osc52ClipboardHandler(clipboardWrites::add)
+
+ handler.process("before${sequence("first", "\u0007")}between${sequence("second", "\u009c")}after")
+
+ assertEquals(listOf("first", "second"), clipboardWrites)
+ }
+
+ @Test
+ fun `ignores queries and malformed payloads`() {
+ val clipboardWrites = mutableListOf()
+ val handler = Osc52ClipboardHandler(clipboardWrites::add)
+
+ handler.process("\u001b]52;c;?\u0007")
+ handler.process("\u001b]52;c;%%%\u0007")
+ handler.process("\u001b]52;missing-separator\u0007")
+
+ assertTrue(clipboardWrites.isEmpty())
+ }
+
+ private fun sequence(text: String, terminator: String): String {
+ val payload = Base64.getEncoder().encodeToString(text.toByteArray())
+ return "\u001b]52;c;$payload$terminator"
+ }
+}
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 9a0166b..3c4051d 100644
--- a/src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt
+++ b/src/test/kotlin/com/ashotn/opencode/relay/terminal/TerminalDataProvidersTest.kt
@@ -1,23 +1,21 @@
package com.ashotn.opencode.relay.terminal
import com.intellij.ide.DataManager
+import com.intellij.execution.filters.OpenFileHyperlinkInfo
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.vfs.newvfs.impl.VfsRootAccess
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.vfs.LocalFileSystem
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.Cursor
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
+import java.awt.event.MouseEvent
import java.io.File
import java.lang.reflect.Proxy
-import java.util.function.Consumer
import javax.swing.JPanel
class TerminalDataProvidersTest : BasePlatformTestCase() {
@@ -46,368 +44,210 @@ class TerminalDataProvidersTest : BasePlatformTestCase() {
}
}
- fun `test embedded terminal data provider installs ctrl z key override without intercepting escape`() {
- val terminalPanel = createTerminalPanel()
- val existingHandlers = preKeyEventHandlers(terminalPanel)
-
- try {
- installEmbeddedTerminalDataProvider(project, 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)
- }
- }
-
- fun `test markdown terminal hyperlink filter resolves project relative file link`() {
- val file = File(project.basePath, "src/main/kotlin/SendFileAction.kt").apply {
- parentFile.mkdirs()
- writeText("class SendFileAction")
- }
- com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)
-
- val line = "Open [SendFileAction](src/main/kotlin/SendFileAction.kt)"
- val result = MarkdownTerminalHyperlinkFilter(project).apply(line)
-
- assertNotNull(result)
- val item = result!!.items.single()
- assertEquals(line.indexOf('['), item.startOffset)
- assertEquals(line.length, item.endOffset)
- }
-
- fun `test markdown terminal hyperlink filter resolves markdown file link with line anchor`() {
- val file = File(project.basePath, "note.md").apply {
- parentFile?.mkdirs()
- writeText("one\ntwo\n")
- }
- com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)
-
- var navigatedLineNumber: Int? = null
- val line = "Open [note.md:2](./note.md#L2)"
- val result = MarkdownTerminalHyperlinkFilter(project) { virtualFile, lineNumber ->
- assertEquals(file.path, virtualFile.path)
- navigatedLineNumber = lineNumber
- }.apply(line)
-
- assertNotNull(result)
- val item = result!!.items.single()
- assertEquals(line.indexOf('['), item.startOffset)
- assertEquals(line.length, item.endOffset)
- item.linkInfo.navigate()
- assertEquals(2, navigatedLineNumber)
- }
+ fun `test embedded terminal consumes only ctrl z key presses`() {
+ val source = JPanel()
+ val cases = listOf(
+ KeyEvent.KEY_PRESSED to InputEvent.CTRL_DOWN_MASK,
+ KeyEvent.KEY_RELEASED to InputEvent.CTRL_DOWN_MASK,
+ KeyEvent.KEY_PRESSED to (InputEvent.CTRL_DOWN_MASK or InputEvent.SHIFT_DOWN_MASK),
+ )
- fun `test markdown terminal hyperlink filter resolves prompt example markdown link`() {
- val file = File(project.basePath, "src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js").apply {
- parentFile.mkdirs()
- writeText("const IDE_GUIDANCE = 'test'\n")
+ cases.forEachIndexed { index, (eventId, modifiers) ->
+ val event = KeyEvent(source, eventId, 0, modifiers, KeyEvent.VK_Z, 'Z')
+ assertEquals(index == 0, consumeEmbeddedTerminalControlKey(event))
}
- com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)
-
- val line = "[./src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js](./src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js)"
- val result = MarkdownTerminalHyperlinkFilter(project).apply(line)
- assertNotNull(result)
- val item = result!!.items.single()
- assertEquals(0, item.startOffset)
- assertEquals(line.length, item.endOffset)
+ val escape = KeyEvent(source, KeyEvent.KEY_PRESSED, 0, 0, KeyEvent.VK_ESCAPE, KeyEvent.CHAR_UNDEFINED)
+ assertFalse(consumeEmbeddedTerminalControlKey(escape))
}
- fun `test markdown terminal hyperlink filter resolves wrapped markdown label path`() {
- val file = File(project.basePath, "src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js").apply {
- parentFile.mkdirs()
- writeText("const IDE_GUIDANCE = 'test'\n")
- }
- com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)
+ fun `test terminal hyperlink mouse guard suppresses press and release but preserves click`() {
+ val root = JPanel()
+ val link = JPanel().apply { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) }
+ root.add(link)
+ val guard = TerminalHyperlinkMouseGuard(root)
- val line = "[./src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js"
- val result = MarkdownTerminalHyperlinkFilter(project).apply(line)
+ assertTrue(guard.dispatch(mouseEvent(link, MouseEvent.MOUSE_PRESSED)))
+ assertTrue(guard.dispatch(mouseEvent(link, MouseEvent.MOUSE_RELEASED)))
+ assertFalse(guard.dispatch(mouseEvent(link, MouseEvent.MOUSE_CLICKED)))
- assertNotNull(result)
- val item = result!!.items.single()
- assertEquals(1, item.startOffset)
- assertEquals(line.length, item.endOffset)
+ link.cursor = Cursor.getDefaultCursor()
+ assertFalse(guard.dispatch(mouseEvent(link, MouseEvent.MOUSE_PRESSED)))
+ assertFalse(guard.dispatch(mouseEvent(link, MouseEvent.MOUSE_RELEASED)))
}
- fun `test markdown terminal hyperlink filter resolves advertised bare file formats`() {
+ fun `test local file reference matcher contract`() {
data class Case(
- val name: String,
+ val line: String,
val target: String,
val file: File,
- val lineNumber: Int? = null,
+ val lineNumber: Int?,
)
- val absoluteFile = File(project.basePath, "absolute/AbsoluteFile.kt")
- val absoluteLineSuffixFile = File(project.basePath, "absolute/AbsoluteLineSuffixFile.kt")
+ val readme = createProjectFile("README.md", "one\ntwo\nthree\n")
+ val nested = createProjectFile("src/main/Example.kt", (1..50).joinToString("\n") { "line $it" })
+ val encoded = createProjectFile("encoded name.md", "content\n")
+ val punctuated = createProjectFile("literal!", "content\n")
+ val directory = File(project.basePath, "local-directory").apply { mkdirs() }
+ listOf(readme, nested, encoded, punctuated, directory).forEach { file ->
+ assertNotNull(LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file))
+ }
+
+ val targetWithLine = "./README.md:2"
val cases = listOf(
- Case(
- "dot slash relative path",
- "./src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js",
- File(project.basePath, "src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js"),
- ),
- Case(
- "project relative path",
- "src/main/kotlin/com/ashotn/opencode/relay/actions/SendFileAction.kt",
- File(project.basePath, "src/main/kotlin/com/ashotn/opencode/relay/actions/SendFileAction.kt"),
- ),
- Case(
- "parent relative path",
- "../src/ParentRelative.kt",
- File(project.basePath, "../src/ParentRelative.kt"),
- ),
- Case(
- "absolute path",
- absoluteFile.path,
- absoluteFile,
- ),
- Case(
- "line anchor without dot slash",
- "note.md#L2",
- File(project.basePath, "note.md"),
- lineNumber = 2,
- ),
- Case(
- "dot slash line anchor",
- "./note.md#L2",
- File(project.basePath, "note.md"),
- lineNumber = 2,
- ),
- Case(
- "line anchor range",
- "./note.md#L2-L6",
- File(project.basePath, "note.md"),
- lineNumber = 2,
- ),
- Case(
- "dot slash line suffix",
- "./src/FileWithLineSuffix.kt:42",
- File(project.basePath, "src/FileWithLineSuffix.kt"),
- lineNumber = 42,
- ),
- Case(
- "project relative line suffix range",
- "src/main/FileWithLineSuffixRange.kt:42-48",
- File(project.basePath, "src/main/FileWithLineSuffixRange.kt"),
- lineNumber = 42,
- ),
- Case(
- "absolute line suffix",
- "${absoluteLineSuffixFile.path}:42",
- absoluteLineSuffixFile,
- lineNumber = 42,
- ),
- )
+ Case("See ./README.md", "./README.md", readme, null),
+ Case("See ${readme.path}", readme.path, readme, null),
+ Case("See src/main/Example.kt:42;", "src/main/Example.kt:42", nested, 42),
+ Case("See ./encoded%20name.md", "./encoded%20name.md", encoded, null),
+ Case("See ./literal!?", "./literal!", punctuated, null),
+ Case("See [./README.md](https://example.test)", "./README.md", readme, null),
+ Case("Open ./local-directory/.", "./local-directory/", directory, null),
+ ) + listOf('.', ',', ':', ';', '!', '?').map { punctuation ->
+ Case("See $targetWithLine$punctuation", targetWithLine, readme, 2)
+ }
+ val matcher = LocalFileReferenceMatcher(project)
cases.forEach { case ->
- case.file.apply {
- parentFile?.mkdirs()
- writeText((1..60).joinToString("\n") { "line $it" } + "\n")
- }
- VfsRootAccess.allowRootAccess(testRootDisposable, case.file.parentFile.canonicalPath)
- com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(case.file)
-
- var navigatedPath: String? = null
- var navigatedLineNumber: Int? = null
- val line = "See ${case.target}"
- val result = MarkdownTerminalHyperlinkFilter(project) { virtualFile, lineNumber ->
- navigatedPath = virtualFile.path
- navigatedLineNumber = lineNumber
- }.apply(line)
-
- assertNotNull(case.name, result)
- val item = result!!.items.single()
- val targetStart = line.indexOf(case.target)
- assertEquals(case.name, targetStart, item.startOffset)
- assertEquals(case.name, targetStart + case.target.length, item.endOffset)
-
- item.linkInfo.navigate()
- assertEquals(case.name, case.file.canonicalPath, File(navigatedPath!!).canonicalPath)
- assertEquals(case.name, case.lineNumber, navigatedLineNumber)
+ val result = matcher.findAll(case.line).single()
+ assertEquals(case.file.path, result.virtualFile.path)
+ assertEquals(case.lineNumber, result.lineNumberOneBased)
+ assertEquals(case.line.indexOf(case.target), result.sourceStartOffset)
+ assertEquals(case.line.indexOf(case.target) + case.target.length, result.sourceEndOffset)
}
+
+ listOf(
+ "See ./missing.md:2",
+ "See ./README.md:0",
+ "See ${readme.toURI()}",
+ "See @src/main/Example.kt#L2",
+ ).forEach { line -> assertTrue(matcher.findAll(line).isEmpty()) }
}
- fun `test markdown terminal hyperlink filter excludes trailing punctuation from bare file links`() {
- data class Case(
- val name: String,
- val text: String,
- val target: String,
- val file: File,
- val lineNumber: Int? = null,
+ /*
+ * File references containing framework-defined route segments must be clickable in the terminal
+ * and open the referenced file at the requested line.
+ *
+ * MANUAL VERIFICATION:
+ * 1. Open a project containing a route file under a `-(parts)` or `[itemId]` folder.
+ * 2. Ask OpenCode to print that file's project-relative path with a line-number suffix.
+ * 3. Click the printed path and confirm the file opens at the requested line.
+ */
+ fun `test local file reference matcher resolves framework route segments`() {
+ val paths = listOf(
+ "src/routes/_zone/items/add/-(parts)/review.ts",
+ "src/app/items/[itemId]/page.tsx",
)
- val cases = listOf(
- Case(
- "trailing period",
- "src/main/FileTrailingPeriod.kt.",
- "src/main/FileTrailingPeriod.kt",
- File(project.basePath, "src/main/FileTrailingPeriod.kt"),
- ),
- Case(
- "trailing comma",
- "src/main/FileTrailingComma.kt,",
- "src/main/FileTrailingComma.kt",
- File(project.basePath, "src/main/FileTrailingComma.kt"),
- ),
- Case(
- "trailing colon",
- "src/main/FileTrailingColon.kt:",
- "src/main/FileTrailingColon.kt",
- File(project.basePath, "src/main/FileTrailingColon.kt"),
- ),
- Case(
- "trailing semicolon",
- "src/main/FileTrailingSemicolon.kt;",
- "src/main/FileTrailingSemicolon.kt",
- File(project.basePath, "src/main/FileTrailingSemicolon.kt"),
- ),
- Case(
- "line suffix trailing period",
- "src/main/FileLineSuffixTrailingPeriod.kt:42.",
- "src/main/FileLineSuffixTrailingPeriod.kt:42",
- File(project.basePath, "src/main/FileLineSuffixTrailingPeriod.kt"),
- lineNumber = 42,
- ),
- Case(
- "line anchor trailing period",
- "note.md#L2.",
- "note.md#L2",
- File(project.basePath, "note.md"),
- lineNumber = 2,
- ),
- )
+ paths.forEach { path ->
+ val file = createProjectFile(path, (1..150).joinToString("\n") { "line $it" })
+ assertNotNull(LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file))
+ val target = "./$path:141"
+ val line = "- $target"
- cases.forEach { case ->
- case.file.apply {
- parentFile?.mkdirs()
- writeText((1..60).joinToString("\n") { "line $it" } + "\n")
- }
- VfsRootAccess.allowRootAccess(testRootDisposable, case.file.parentFile.canonicalPath)
- com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(case.file)
-
- var navigatedPath: String? = null
- var navigatedLineNumber: Int? = null
- val line = "See ${case.text}"
- val result = MarkdownTerminalHyperlinkFilter(project) { virtualFile, lineNumber ->
- navigatedPath = virtualFile.path
- navigatedLineNumber = lineNumber
- }.apply(line)
-
- assertNotNull(case.name, result)
- val item = result!!.items.single()
- val targetStart = line.indexOf(case.target)
- assertEquals(case.name, targetStart, item.startOffset)
- assertEquals(case.name, targetStart + case.target.length, item.endOffset)
-
- item.linkInfo.navigate()
- assertEquals(case.name, case.file.canonicalPath, File(navigatedPath!!).canonicalPath)
- assertEquals(case.name, case.lineNumber, navigatedLineNumber)
- }
- }
+ val result = LocalFileReferenceMatcher(project).findAll(line).single()
- fun `test markdown terminal hyperlink filter resolves wrapped markdown label path with line suffix`() {
- val file = File(project.basePath, "src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js").apply {
- parentFile.mkdirs()
- writeText("one\ntwo\n")
+ assertEquals(file.path, result.virtualFile.path)
+ assertEquals(141, result.lineNumberOneBased)
+ assertEquals(line.indexOf(target), result.sourceStartOffset)
+ assertEquals(line.indexOf(target) + target.length, result.sourceEndOffset)
}
- com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)
-
- var navigatedLineNumber: Int? = null
- val line = "[./src/main/resources/opencode-relay/plugins/opencode-relay-prompt.js:2"
- val result = MarkdownTerminalHyperlinkFilter(project) { virtualFile, lineNumber ->
- assertEquals(file.path, virtualFile.path)
- navigatedLineNumber = lineNumber
- }.apply(line)
-
- assertNotNull(result)
- val item = result!!.items.single()
- assertEquals(1, item.startOffset)
- assertEquals(line.length, item.endOffset)
- item.linkInfo.navigate()
- assertEquals(2, navigatedLineNumber)
}
- fun `test markdown terminal hyperlink filter resolves markdown line range to first line`() {
- val file = File(project.basePath, "note.md").apply {
- parentFile?.mkdirs()
- writeText((1..6).joinToString("\n") { "line $it" } + "\n")
- }
- com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)
-
- var navigatedLineNumber: Int? = null
- val line = "[`note.md` lines 2-6](./note.md#L2-L6)"
- val result = MarkdownTerminalHyperlinkFilter(project) { virtualFile, lineNumber ->
- assertEquals(file.path, virtualFile.path)
- navigatedLineNumber = lineNumber
- }.apply(line)
-
- assertNotNull(result)
- val item = result!!.items.single()
- assertEquals(line.indexOf('['), item.startOffset)
- assertEquals(line.length, item.endOffset)
- item.linkInfo.navigate()
- assertEquals(2, navigatedLineNumber)
- }
+ fun `test Classic adapter maps local file reference to JediTerm link`() {
+ val file = createProjectFile("classic.kt", "one\ntwo\n")
+ assertNotNull(LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file))
+ val target = "./classic.kt:2"
+ val line = "See $target."
+ var navigatedFile: VirtualFileAndLine? = null
- fun `test markdown terminal hyperlink filter resolves bare line anchor inside html code tag`() {
- val file = File(project.basePath, "note.md").apply {
- parentFile?.mkdirs()
- writeText((1..12).joinToString("\n") { "line $it" } + "\n")
- }
- com.intellij.openapi.vfs.LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)
-
- var navigatedLineNumber: Int? = null
- val line = "
./note.md#L12
"
- val result = MarkdownTerminalHyperlinkFilter(project) { virtualFile, lineNumber ->
- assertEquals(file.path, virtualFile.path)
- navigatedLineNumber = lineNumber
- }.apply(line)
-
- assertNotNull(result)
- val item = result!!.items.single()
- val targetStart = line.indexOf("./note.md#L12")
- assertEquals(targetStart, item.startOffset)
- assertEquals(targetStart + "./note.md#L12".length, item.endOffset)
+ val item = ClassicTerminalHyperlinkFilter(project) { virtualFile, lineNumber ->
+ navigatedFile = VirtualFileAndLine(virtualFile.path, lineNumber)
+ }.apply(line)!!.items.single()
+
+ assertEquals(line.indexOf(target), item.startOffset)
+ assertEquals(line.indexOf(target) + target.length, item.endOffset)
item.linkInfo.navigate()
- assertEquals(12, navigatedLineNumber)
+ assertEquals(VirtualFileAndLine(file.path, 2), navigatedFile)
}
- fun `test markdown terminal hyperlink filter ignores missing project relative file link`() {
- val result = MarkdownTerminalHyperlinkFilter(project)
- .apply("Open [Missing](src/main/kotlin/Missing.kt)")
+ fun `test Reworked adapter maps local file reference to platform hyperlink`() {
+ val file = createProjectFile("reworked.kt", "one\ntwo\n")
+ val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file)
+ assertNotNull(virtualFile)
+ val target = "./reworked.kt:2"
+ val line = "| Source | $target |"
+
+ val item = createOpenCodeFileMentionFilter(project).applyFilter(line, line.length)!!.resultItems.single()
+
+ assertEquals(line.indexOf(target), item.highlightStartOffset)
+ assertEquals(line.indexOf(target) + target.length, item.highlightEndOffset)
+ val hyperlink = item.hyperlinkInfo as OpenFileHyperlinkInfo
+ assertEquals(virtualFile!!.path, hyperlink.virtualFile?.path)
+ hyperlink.navigate(project)
+ assertEquals(1, FileEditorManager.getInstance(project).selectedTextEditor!!.caretModel.logicalPosition.line)
+ }
- assertNull(result)
+ fun `test OpenCode file mention filter resolves line ranges from the project root`() {
+ val firstFile = createProjectFile("note.md", "one\ntwo\nthree\n")
+ val secondFile = createProjectFile("src/File.kt", "one\ntwo\n")
+ val firstVirtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(firstFile)
+ val secondVirtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(secondFile)
+ assertNotNull(firstVirtualFile)
+ assertNotNull(secondVirtualFile)
+ val line = "Review @note.md#L2-3, then @src/File.kt#L1."
+
+ val result = createOpenCodeFileMentionFilter(project).applyFilter(line, line.length)
+
+ val items = result!!.resultItems
+ assertEquals(2, items.size)
+ val firstMention = "@note.md#L2-3"
+ assertEquals(line.indexOf(firstMention), items[0].highlightStartOffset)
+ assertEquals(line.indexOf(firstMention) + firstMention.length, items[0].highlightEndOffset)
+ val firstHyperlink = items[0].hyperlinkInfo as OpenFileHyperlinkInfo
+ assertEquals(firstVirtualFile!!.path, firstHyperlink.virtualFile?.path)
+ assertEquals(secondVirtualFile!!.path, (items[1].hyperlinkInfo as OpenFileHyperlinkInfo).virtualFile?.path)
+ firstHyperlink.navigate(project)
+ assertEquals(1, FileEditorManager.getInstance(project).selectedTextEditor!!.caretModel.logicalPosition.line)
}
- fun `test markdown terminal hyperlink filter ignores uri-like line anchor without crashing`() {
- val result = MarkdownTerminalHyperlinkFilter(project)
- .apply("mailto:test@example.com#L1")
+ fun `test OpenCode file mention filter rejects malformed line anchors`() {
+ val file = createProjectFile("note.md", "one\ntwo\nthree\n")
+ assertNotNull(LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file))
+ val filter = createOpenCodeFileMentionFilter(project)
+
+ assertNull(filter.applyFilter("@note.md#L0", "@note.md#L0".length))
+ assertNull(filter.applyFilter("@note.md#L2abc", "@note.md#L2abc".length))
+ assertNull(filter.applyFilter("@note.md#L2-3foo", "@note.md#L2-3foo".length))
+ val overflow = "@note.md#L99999999999999999999"
+ assertNull(filter.applyFilter(overflow, overflow.length))
+ val validAfterOverflow = "$overflow @note.md#L2"
+ assertEquals(1, filter.applyFilter(validAfterOverflow, validAfterOverflow.length)!!.resultItems.size)
+ }
- assertNull(result)
+ fun `test OpenCode file mention filter resolves a trailing slash directory mention only`() {
+ val directory = File(project.basePath, "src").apply { mkdirs() }
+ val virtualDirectory = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(directory)
+ assertNotNull(virtualDirectory)
+ val filter = createOpenCodeFileMentionFilter(project)
+ val mention = "@src/"
+ val line = "Open $mention, ask @agent, or email dev@example.com."
+
+ val result = filter.applyFilter(line, line.length)
+
+ assertEquals(1, result!!.resultItems.size)
+ val item = result.resultItems[0]
+ assertEquals(line.indexOf(mention), item.highlightStartOffset)
+ assertEquals(line.indexOf(mention) + mention.length, item.highlightEndOffset)
+ val hyperlink = item.hyperlinkInfo as OpenFileHyperlinkInfo
+ assertEquals(virtualDirectory!!.path, hyperlink.virtualFile?.path)
+ assertTrue(hyperlink.virtualFile!!.isDirectory)
+
+ val ambiguous = "Ignore @agent, dev@example.com, and @src/main"
+ assertNull(filter.applyFilter(ambiguous, ambiguous.length))
+
+ val projectRoot = filter.applyFilter("Open @./", "Open @./".length)!!.resultItems.single()
+ val projectRootHyperlink = projectRoot.hyperlinkInfo as OpenFileHyperlinkInfo
+ assertEquals(project.basePath, projectRootHyperlink.virtualFile?.path)
}
private fun toolWindowStub(): ToolWindow =
@@ -421,31 +261,17 @@ class TerminalDataProvidersTest : BasePlatformTestCase() {
}
} as ToolWindow
- private fun createTerminalPanel(): JBTerminalPanel {
- lateinit var terminalPanel: JBTerminalPanel
- ApplicationManager.getApplication().invokeAndWait {
- val styleState = StyleState()
- terminalPanel = JBTerminalPanel(
- JBTerminalSystemSettingsProviderBase(),
- TerminalTextBuffer(80, 24, styleState),
- styleState,
- )
+ private fun createProjectFile(path: String, content: String): File =
+ File(project.basePath, path).apply {
+ parentFile.mkdirs()
+ writeText(content)
}
- return 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 mouseEvent(source: JPanel, id: Int): MouseEvent =
+ MouseEvent(source, id, 0, 0, 0, 0, 1, false, MouseEvent.BUTTON1)
- 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) { })
- }
- }
+ private data class VirtualFileAndLine(
+ val path: String,
+ val lineNumber: Int?,
+ )
}
diff --git a/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/DiffPipelineHarness.kt b/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/DiffPipelineHarness.kt
index 2a6f8cf..0f9bc2f 100644
--- a/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/DiffPipelineHarness.kt
+++ b/src/testFixtures/kotlin/com/ashotn/opencode/relay/core/DiffPipelineHarness.kt
@@ -146,22 +146,6 @@ class DiffPipelineHarness(
fun baseline(relPath: String): String? =
stateStore.baselineBeforeBySessionAndFile[sessionId]?.get(abs(relPath))
- fun selectCurrentSession() {
- stateStore.commitSelectedSession(
- stateLock = stateLock,
- requestedSessionId = sessionId,
- sessionExists = { true },
- )
- }
-
- fun selectedSessionId(): String? = stateStore.selectedSessionId
-
- fun resetState() {
- stateStore.resetState()
- }
-
- fun stateStoreForAssertions(): Any = stateStore
-
fun reconcileCurrentState() {
val snapshot = stateStore.snapshotSessionReconcileState(
stateLock = stateLock,