aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-08-10 14:57:44 +0300
committerGitHub <noreply@github.com>2026-08-10 14:57:44 +0300
commitccccda54c21f6ef1649a44bb098d7c498724404d (patch)
tree86f28b85086ef36fd7ebc0f1d928b7606d76c487 /src/integration
parent2e8cc9060d723a5ac32a2325e695fd8750ff60fe (diff)
fix: track opencode tui session selection (#2455)
refs #2450 Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/actions.rs46
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js17
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.test.ts31
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.js113
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.test.ts181
-rw-r--r--src/integration/mod.rs6
-rw-r--r--src/integration/opencode_config.rs298
-rw-r--r--src/integration/registry.rs22
-rw-r--r--src/integration/targets.rs51
-rw-r--r--src/integration/tests.rs115
-rw-r--r--src/integration/types.rs6
11 files changed, 847 insertions, 39 deletions
diff --git a/src/integration/actions.rs b/src/integration/actions.rs
index 3cb73774..00bf8c8f 100644
--- a/src/integration/actions.rs
+++ b/src/integration/actions.rs
@@ -144,10 +144,20 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re
}
crate::api::schema::IntegrationTarget::Opencode => {
let installed = install_opencode()?;
- vec![format!(
- "installed opencode integration plugin to {}",
- installed.plugin_path.display()
- )]
+ vec![
+ format!(
+ "installed opencode integration plugin to {}",
+ installed.plugin_path.display()
+ ),
+ format!(
+ "installed opencode tui integration plugin to {}",
+ installed.tui_plugin_path.display()
+ ),
+ format!(
+ "ensured opencode tui plugin config at {}",
+ installed.tui_config_path.display()
+ ),
+ ]
}
crate::api::schema::IntegrationTarget::Kilo => {
let installed = install_kilo()?;
@@ -451,17 +461,35 @@ pub(crate) fn uninstall_target(
}
crate::api::schema::IntegrationTarget::Opencode => {
let result = uninstall_opencode()?;
- if result.removed_plugin {
- vec![format!(
+ let mut messages = vec![if result.removed_plugin {
+ format!(
"removed opencode integration plugin at {}",
result.plugin_path.display()
- )]
+ )
} else {
- vec![format!(
+ format!(
"no opencode integration plugin found at {}",
result.plugin_path.display()
- )]
+ )
+ }];
+ messages.push(if result.removed_tui_plugin {
+ format!(
+ "removed opencode tui integration plugin at {}",
+ result.tui_plugin_path.display()
+ )
+ } else {
+ format!(
+ "no opencode tui integration plugin found at {}",
+ result.tui_plugin_path.display()
+ )
+ });
+ if result.updated_tui_config {
+ messages.push(format!(
+ "removed herdr opencode plugin entry from {}",
+ result.tui_config_path.display()
+ ));
}
+ messages
}
crate::api::schema::IntegrationTarget::Kilo => {
let result = uninstall_kilo()?;
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
index 76265050..2840c221 100644
--- a/src/integration/assets/opencode/herdr-agent-state.js
+++ b/src/integration/assets/opencode/herdr-agent-state.js
@@ -2,7 +2,7 @@
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=opencode
-// HERDR_INTEGRATION_VERSION=9
+// HERDR_INTEGRATION_VERSION=10
import net from "node:net";
@@ -102,15 +102,11 @@ function requestOnce(method, params) {
});
}
-function reportSession(sessionID, sessionStartSource) {
+function reportSession(sessionID) {
if (!sessionID) {
return Promise.resolve();
}
- const params = { agent_session_id: sessionID };
- if (sessionStartSource) {
- params.session_start_source = sessionStartSource;
- }
- return request("pane.report_agent_session", params);
+ return request("pane.report_agent_session", { agent_session_id: sessionID });
}
function reportState(state, sessionID) {
@@ -157,10 +153,9 @@ export const HerdrAgentStatePlugin = async () => {
switch (type) {
case "session.created":
- // A root session.created is a genuine new-session start (subagent
- // creates are dropped above). Signal it so herdr replaces the pane's
- // prior session id instead of treating the change as cross-talk.
- await reportSession(sessionID, "new");
+ // Creation is server-global, so an attached client may own it. The
+ // TUI plugin separately reports the root selected in this pane.
+ reportedRootSessionID = sessionID;
break;
case "session.updated":
if (sessionID && sessionID !== reportedRootSessionID) {
diff --git a/src/integration/assets/opencode/herdr-agent-state.test.ts b/src/integration/assets/opencode/herdr-agent-state.test.ts
index be21fd71..6f8ee590 100644
--- a/src/integration/assets/opencode/herdr-agent-state.test.ts
+++ b/src/integration/assets/opencode/herdr-agent-state.test.ts
@@ -114,6 +114,37 @@ test("suppresses redundant same-session updates", async () => {
expect(requests.map(requestSessionID)).toEqual(["root-session", "replacement-session"]);
});
+test("does not classify server activity in another root session as a selection", async () => {
+ const plugin = await loadPlugin();
+
+ await plugin["chat.message"]({ sessionID: "visible-session" });
+ await plugin["chat.message"]({ sessionID: "attached-client-session" });
+
+ expect(requests.map(requestMethod)).toEqual([
+ "pane.report_agent",
+ "pane.report_agent",
+ ]);
+ expect(requests.map(requestSessionID)).toEqual([
+ "visible-session",
+ "attached-client-session",
+ ]);
+});
+
+test("does not classify server-global root creation as a local selection", async () => {
+ const plugin = await loadPlugin();
+
+ await plugin.event({
+ event: { type: "session.created", properties: { sessionID: "attached-session" } },
+ });
+ await plugin.event({
+ event: { type: "session.updated", properties: { sessionID: "attached-session" } },
+ });
+ await plugin["chat.message"]({ sessionID: "attached-session" });
+
+ expect(requests.map(requestMethod)).toEqual(["pane.report_agent"]);
+ expect(requests.map(requestSessionID)).toEqual(["attached-session"]);
+});
+
test("reports retry status as working", async () => {
const plugin = await loadPlugin();
diff --git a/src/integration/assets/opencode/herdr-tui-session.js b/src/integration/assets/opencode/herdr-tui-session.js
new file mode 100644
index 00000000..b5be5bbc
--- /dev/null
+++ b/src/integration/assets/opencode/herdr-tui-session.js
@@ -0,0 +1,113 @@
+// installed by herdr
+// managed by herdr; reinstalling or updating the integration overwrites this file.
+// HERDR_INTEGRATION_ID=opencode-tui
+// HERDR_INTEGRATION_VERSION=10
+
+import net from "node:net";
+
+const SOURCE = "herdr:opencode";
+const AGENT = "opencode";
+const ROUTE_POLL_INTERVAL_MS = 100;
+const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000];
+
+function requestOnce(sessionID) {
+ const paneId = process.env.HERDR_PANE_ID;
+ const socketPath = process.env.HERDR_SOCKET_PATH;
+ if (!paneId || !socketPath) {
+ return Promise.resolve();
+ }
+
+ const socketEndpoint =
+ process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
+ const request = {
+ id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
+ .toString()
+ .padStart(6, "0")}`,
+ method: "pane.report_agent_session",
+ params: {
+ pane_id: paneId,
+ source: SOURCE,
+ agent: AGENT,
+ agent_session_id: sessionID,
+ session_start_source: "select",
+ },
+ };
+
+ return new Promise((resolve) => {
+ const client = net.createConnection(socketEndpoint, () => {
+ client.write(`${JSON.stringify(request)}\n`);
+ });
+ const finish = () => {
+ client.destroy();
+ resolve();
+ };
+
+ client.setTimeout(500, finish);
+ client.on("data", finish);
+ client.on("error", finish);
+ client.on("end", finish);
+ client.on("close", resolve);
+ });
+}
+
+export default {
+ id: "herdr.opencode.session-selection",
+ tui: async (api) => {
+ if (
+ process.env.HERDR_ENV !== "1" ||
+ !process.env.HERDR_SOCKET_PATH ||
+ !process.env.HERDR_PANE_ID
+ ) {
+ return;
+ }
+
+ let selectedSessionID;
+ let retryIndex = 0;
+ let nextReportAt = 0;
+ let reportPending = false;
+ const syncSelectedSession = async () => {
+ const route = api.route.current;
+ const sessionID = route?.name === "session" ? route.params?.sessionID : undefined;
+ const session =
+ typeof sessionID === "string" && sessionID
+ ? api.state.session.get(sessionID)
+ : undefined;
+ if (!session || session.parentID) {
+ selectedSessionID = undefined;
+ retryIndex = 0;
+ nextReportAt = 0;
+ return;
+ }
+ if (sessionID !== selectedSessionID) {
+ selectedSessionID = sessionID;
+ retryIndex = 0;
+ nextReportAt = 0;
+ }
+ if (reportPending || Date.now() < nextReportAt) {
+ return;
+ }
+
+ const reportingSessionID = sessionID;
+ reportPending = true;
+ try {
+ await requestOnce(reportingSessionID);
+ } catch {
+ // Best-effort reporting retries below while the selected route remains active.
+ } finally {
+ reportPending = false;
+ }
+ if (selectedSessionID !== reportingSessionID) {
+ retryIndex = 0;
+ nextReportAt = 0;
+ return;
+ }
+ const retryDelay = SELECTION_RETRY_DELAYS_MS[retryIndex];
+ retryIndex += 1;
+ nextReportAt = retryDelay === undefined ? Number.POSITIVE_INFINITY : Date.now() + retryDelay;
+ };
+
+ await syncSelectedSession();
+ const routePoll = setInterval(() => void syncSelectedSession(), ROUTE_POLL_INTERVAL_MS);
+ api.lifecycle.onDispose(() => clearInterval(routePoll));
+ },
+};
diff --git a/src/integration/assets/opencode/herdr-tui-session.test.ts b/src/integration/assets/opencode/herdr-tui-session.test.ts
new file mode 100644
index 00000000..1bcb1f81
--- /dev/null
+++ b/src/integration/assets/opencode/herdr-tui-session.test.ts
@@ -0,0 +1,181 @@
+import { afterEach, beforeEach, expect, mock, test } from "bun:test";
+
+const requests: unknown[] = [];
+const activeDisposers: Array<() => void> = [];
+const requestWaiters: Array<() => void> = [];
+let importCounter = 0;
+
+mock.module("node:net", () => ({
+ default: {
+ createConnection(_path: string, onConnect: () => void) {
+ const handlers = new Map<string, () => void>();
+ const client = {
+ write(input: string) {
+ requests.push(JSON.parse(input.trim()));
+ requestWaiters.shift()?.();
+ queueMicrotask(() => client.emit("data"));
+ },
+ setTimeout() {},
+ on(event: string, handler: () => void) {
+ handlers.set(event, handler);
+ },
+ destroy() {},
+ emit(event: string) {
+ handlers.get(event)?.();
+ },
+ };
+ queueMicrotask(onConnect);
+ return client;
+ },
+ },
+}));
+
+beforeEach(() => {
+ requests.length = 0;
+ requestWaiters.length = 0;
+ process.env.HERDR_ENV = "1";
+ process.env.HERDR_SOCKET_PATH = "test.sock";
+ process.env.HERDR_PANE_ID = "test:p1";
+});
+
+afterEach(() => {
+ for (const dispose of activeDisposers.splice(0)) {
+ dispose();
+ }
+});
+
+async function loadPlugin() {
+ importCounter += 1;
+ const module = await import(`./herdr-tui-session.js?test=${importCounter}`);
+ return module.default;
+}
+
+function fakeApi() {
+ const sessions = new Map<string, { id: string; parentID?: string }>();
+ let current: { name: string; params?: { sessionID: string } } = { name: "home" };
+ let dispose: (() => void) | undefined;
+ activeDisposers.push(() => dispose?.());
+
+ return {
+ api: {
+ route: {
+ get current() {
+ return current;
+ },
+ },
+ state: {
+ session: {
+ get(sessionID: string) {
+ return sessions.get(sessionID);
+ },
+ },
+ },
+ lifecycle: {
+ onDispose(handler: () => void) {
+ dispose = handler;
+ return () => {};
+ },
+ },
+ },
+ addSession(session: { id: string; parentID?: string }) {
+ sessions.set(session.id, session);
+ },
+ select(sessionID: string) {
+ current = { name: "session", params: { sessionID } };
+ },
+ dispose() {
+ dispose?.();
+ },
+ };
+}
+
+function waitForNextRequest(): Promise<void> {
+ return new Promise((resolve) => requestWaiters.push(resolve));
+}
+
+test("reports a root session when only the local route changes", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ await plugin.tui(tui.api);
+
+ const dispatched = waitForNextRequest();
+ tui.select("session-a");
+ await dispatched;
+
+ expect(requests).toHaveLength(1);
+ expect(requestParam(requests[0], "agent_session_id")).toBe("session-a");
+ expect(requestParam(requests[0], "session_start_source")).toBe("select");
+ expect(requestParam(requests[0], "seq")).toBeUndefined();
+});
+
+test("retries an initial selection while Herdr detects the process", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ tui.select("session-a");
+
+ await plugin.tui(tui.api);
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests.map((request) => requestParam(request, "agent_session_id"))).toEqual([
+ "session-a",
+ "session-a",
+ ]);
+});
+
+test("does not report root sessions not selected by this TUI", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ tui.addSession({ id: "session-b" });
+ tui.select("session-a");
+ await plugin.tui(tui.api);
+
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests.length).toBeGreaterThan(0);
+ expect(requests.every((request) => requestParam(request, "agent_session_id") === "session-a")).toBe(
+ true,
+ );
+});
+
+test("does not replace the root session with a selected child session", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "root-session" });
+ tui.addSession({ id: "child-session", parentID: "root-session" });
+ tui.select("root-session");
+ await plugin.tui(tui.api);
+ expect(requests).toHaveLength(1);
+
+ tui.select("child-session");
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests).toHaveLength(1);
+ expect(requestParam(requests[0], "agent_session_id")).toBe("root-session");
+});
+
+test("stops route polling when the TUI plugin is disposed", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ await plugin.tui(tui.api);
+ tui.dispose();
+ tui.select("session-a");
+
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests).toHaveLength(0);
+});
+
+function requestParam(request: unknown, name: string): unknown {
+ if (!isRecord(request) || !isRecord(request.params)) {
+ return undefined;
+ }
+ return request.params[name];
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null;
+}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 89d4d34d..9630b578 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -4,6 +4,7 @@ mod command;
mod config_edit;
mod env;
mod file_ops;
+mod opencode_config;
mod registry;
mod targets;
mod types;
@@ -166,7 +167,10 @@ const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [
];
const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js");
-const OPENCODE_INTEGRATION_VERSION: u32 = 9;
+const OPENCODE_TUI_PLUGIN_INSTALL_NAME: &str = "herdr-tui-session.js";
+const OPENCODE_TUI_PLUGIN_SPEC: &str = "./herdr-tui-session.js";
+const OPENCODE_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-tui-session.js");
+const OPENCODE_INTEGRATION_VERSION: u32 = 10;
const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js");
const KILO_INTEGRATION_VERSION: u32 = 4;
diff --git a/src/integration/opencode_config.rs b/src/integration/opencode_config.rs
new file mode 100644
index 00000000..57431f93
--- /dev/null
+++ b/src/integration/opencode_config.rs
@@ -0,0 +1,298 @@
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+
+use jsonc_parser::cst::{CstInputValue, CstRootNode};
+use jsonc_parser::ParseOptions;
+use serde_json::Value;
+
+const TUI_CONFIG_NAME: &str = "tui.jsonc";
+
+pub(crate) fn tui_config_path(config_dir: &Path) -> PathBuf {
+ config_dir.join(TUI_CONFIG_NAME)
+}
+
+pub(crate) fn validate_tui_plugin_config(config_dir: &Path) -> io::Result<()> {
+ let config_path = tui_config_path(config_dir);
+ if !config_path.is_file() {
+ return Ok(());
+ }
+
+ let content = fs::read_to_string(&config_path)?;
+ let root = parse_root(&content, &config_path)?;
+ let object = root_object(&root, &config_path)?;
+ if object
+ .get("plugin")
+ .is_some_and(|property| property.array_value().is_none())
+ {
+ return Err(invalid_plugin_list(&config_path));
+ }
+ Ok(())
+}
+
+pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<PathBuf> {
+ let config_path = tui_config_path(config_dir);
+ let content = if config_path.is_file() {
+ fs::read_to_string(&config_path)?
+ } else {
+ "{}\n".to_string()
+ };
+ let root = parse_root(&content, &config_path)?;
+ let object = root_object(&root, &config_path)?;
+
+ match object.get("plugin") {
+ Some(property) => {
+ let plugins = property
+ .array_value()
+ .ok_or_else(|| invalid_plugin_list(&config_path))?;
+ if plugins.elements().iter().any(|entry| {
+ entry
+ .to_serde_value()
+ .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec))
+ }) {
+ return Ok(config_path);
+ }
+ plugins.append(CstInputValue::String(plugin_spec.to_string()));
+ }
+ None => {
+ object.append(
+ "plugin",
+ CstInputValue::Array(vec![CstInputValue::String(plugin_spec.to_string())]),
+ );
+ }
+ }
+
+ fs::write(&config_path, root.to_string())?;
+ Ok(config_path)
+}
+
+pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> {
+ let config_path = tui_config_path(config_dir);
+ if !config_path.is_file() {
+ return Ok(false);
+ }
+
+ let content = fs::read_to_string(&config_path)?;
+ let root = parse_root(&content, &config_path)?;
+ let object = root_object(&root, &config_path)?;
+ let Some(property) = object.get("plugin") else {
+ return Ok(false);
+ };
+ let plugins = property
+ .array_value()
+ .ok_or_else(|| invalid_plugin_list(&config_path))?;
+ let mut removed = false;
+ for entry in plugins.elements() {
+ if entry
+ .to_serde_value()
+ .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec))
+ {
+ entry.remove();
+ removed = true;
+ }
+ }
+ if !removed {
+ return Ok(false);
+ }
+ if plugins.elements().is_empty() {
+ property.remove();
+ }
+
+ fs::write(&config_path, root.to_string())?;
+ Ok(true)
+}
+
+pub(crate) fn tui_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool {
+ let config_path = tui_config_path(config_dir);
+ let Ok(content) = fs::read_to_string(&config_path) else {
+ return false;
+ };
+ let Ok(root) = parse_root(&content, &config_path) else {
+ return false;
+ };
+ let Ok(object) = root_object(&root, &config_path) else {
+ return false;
+ };
+ object
+ .get("plugin")
+ .and_then(|property| property.array_value())
+ .is_some_and(|plugins| {
+ plugins.elements().iter().any(|entry| {
+ entry
+ .to_serde_value()
+ .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec))
+ })
+ })
+}
+
+fn parse_root(content: &str, path: &Path) -> io::Result<CstRootNode> {
+ CstRootNode::parse(content, &jsonc_parse_options()).map_err(|err| {
+ io::Error::other(format!(
+ "failed to parse OpenCode TUI config at {}: {err}",
+ path.display()
+ ))
+ })
+}
+
+fn root_object(root: &CstRootNode, path: &Path) -> io::Result<jsonc_parser::cst::CstObject> {
+ root.value()
+ .and_then(|value| value.as_object())
+ .ok_or_else(|| invalid_root(path))
+}
+
+fn jsonc_parse_options() -> ParseOptions {
+ ParseOptions {
+ allow_comments: true,
+ allow_loose_object_property_names: false,
+ allow_trailing_commas: true,
+ allow_missing_commas: false,
+ allow_single_quoted_strings: false,
+ allow_hexadecimal_numbers: false,
+ allow_unary_plus_numbers: false,
+ }
+}
+
+fn plugin_entry_matches(entry: &Value, plugin_spec: &str) -> bool {
+ entry.as_str() == Some(plugin_spec)
+ || entry
+ .as_array()
+ .and_then(|parts| parts.first())
+ .and_then(Value::as_str)
+ == Some(plugin_spec)
+}
+
+fn invalid_root(path: &Path) -> io::Error {
+ io::Error::other(format!(
+ "OpenCode TUI config at {} must be a JSON object",
+ path.display()
+ ))
+}
+
+fn invalid_plugin_list(path: &Path) -> io::Error {
+ io::Error::other(format!(
+ "OpenCode TUI config plugin list at {} must be an array",
+ path.display()
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn unique_dir() -> PathBuf {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-opencode-config-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock should be after epoch")
+ .as_nanos()
+ ));
+ fs::create_dir_all(&dir).expect("temporary config directory should be created");
+ dir
+ }
+
+ fn parse_config(path: &Path) -> Value {
+ let content = fs::read_to_string(path).unwrap();
+ parse_root(&content, path)
+ .unwrap()
+ .value()
+ .and_then(|value| value.to_serde_value())
+ .unwrap()
+ }
+
+ #[test]
+ fn add_and_remove_tui_plugin_preserves_jsonc_config() {
+ let dir = unique_dir();
+ let config_path = dir.join(TUI_CONFIG_NAME);
+ fs::write(
+ &config_path,
+ concat!(
+ "{\n",
+ " // Keep this comment.\n",
+ " \"theme\": \"system\",\n",
+ " \"plugin\": [\"example\", [\"configured\", {\"enabled\": true}]],\n",
+ "}\n",
+ ),
+ )
+ .unwrap();
+
+ add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
+ add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
+ let installed_content = fs::read_to_string(&config_path).unwrap();
+ assert!(installed_content.contains("// Keep this comment."));
+ let installed = parse_config(&config_path);
+ assert_eq!(installed["theme"], "system");
+ assert_eq!(
+ installed["plugin"],
+ json!([
+ "example",
+ ["configured", {"enabled": true}],
+ "./herdr-tui-state.js"
+ ])
+ );
+
+ assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ let removed_content = fs::read_to_string(&config_path).unwrap();
+ assert!(removed_content.contains("// Keep this comment."));
+ let removed = parse_config(&config_path);
+ assert_eq!(removed["theme"], "system");
+ assert_eq!(
+ removed["plugin"],
+ json!(["example", ["configured", {"enabled": true}]])
+ );
+
+ fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn managed_jsonc_leaves_opencode_migration_target_absent() {
+ let dir = unique_dir();
+ let legacy_config_path = dir.join("opencode.json");
+ let legacy_config = "{\n \"theme\": \"system\"\n}\n";
+ fs::write(&legacy_config_path, legacy_config).unwrap();
+
+ let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
+
+ assert_eq!(config_path, dir.join("tui.jsonc"));
+ assert!(!dir.join("tui.json").exists());
+ assert_eq!(
+ fs::read_to_string(legacy_config_path).unwrap(),
+ legacy_config
+ );
+ assert_eq!(
+ parse_config(&config_path),
+ json!({ "plugin": ["./herdr-tui-state.js"] })
+ );
+
+ fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn remove_tui_plugin_leaves_empty_managed_config() {
+ let dir = unique_dir();
+ let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
+
+ assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ assert!(config_path.is_file());
+ assert_eq!(parse_config(&config_path), json!({}));
+
+ fs::remove_dir_all(dir).unwrap();
+ }
+
+ #[test]
+ fn configured_tui_plugin_accepts_option_tuple() {
+ let dir = unique_dir();
+ fs::write(
+ dir.join(TUI_CONFIG_NAME),
+ r#"{"plugin":[["./herdr-tui-state.js",{"enabled":true}]]}"#,
+ )
+ .unwrap();
+
+ assert!(tui_plugin_is_configured(&dir, "./herdr-tui-state.js"));
+ assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+
+ fs::remove_dir_all(dir).unwrap();
+ }
+}
diff --git a/src/integration/registry.rs b/src/integration/registry.rs
index 569494a2..b7bb3bf2 100644
--- a/src/integration/registry.rs
+++ b/src/integration/registry.rs
@@ -405,6 +405,22 @@ fn grok_hook_config_is_valid(hook_path: &Path) -> bool {
.is_some_and(|config| config == super::targets::grok_hook_config(hook_path))
}
+fn opencode_tui_integration_is_valid(plugin_path: &Path, expected_version: u32) -> bool {
+ let Some(config_dir) = plugin_path.parent().and_then(Path::parent) else {
+ return false;
+ };
+ let tui_plugin_path = config_dir.join(super::OPENCODE_TUI_PLUGIN_INSTALL_NAME);
+ let tui_plugin_current = fs::read_to_string(tui_plugin_path)
+ .ok()
+ .and_then(|content| parse_integration_version(&content))
+ .is_some_and(|version| version >= expected_version);
+ tui_plugin_current
+ && super::opencode_config::tui_plugin_is_configured(
+ config_dir,
+ super::OPENCODE_TUI_PLUGIN_SPEC,
+ )
+}
+
pub(crate) fn integration_status_at(
target: crate::api::schema::IntegrationTarget,
path: PathBuf,
@@ -439,6 +455,12 @@ pub(crate) fn integration_status_at(
{
state = super::IntegrationStatusKind::Outdated;
}
+ if target == crate::api::schema::IntegrationTarget::Opencode
+ && state == super::IntegrationStatusKind::Current
+ && !opencode_tui_integration_is_valid(&path, expected_version)
+ {
+ state = super::IntegrationStatusKind::Outdated;
+ }
super::IntegrationStatus {
target,
diff --git a/src/integration/targets.rs b/src/integration/targets.rs
index 0cf2f933..375d2713 100644
--- a/src/integration/targets.rs
+++ b/src/integration/targets.rs
@@ -25,6 +25,9 @@ use super::env::{
use super::file_ops::{
make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file,
};
+use super::opencode_config::{
+ add_tui_plugin, remove_tui_plugin, tui_config_path, validate_tui_plugin_config,
+};
use super::types::{
AntigravityCliInstallPaths, AntigravityCliUninstallResult, ClaudeInstallPaths,
ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, CopilotInstallPaths,
@@ -49,9 +52,9 @@ use super::{
KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS,
MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS,
OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET,
- OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME,
- QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME,
- QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS,
+ OPENCODE_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_ASSET, OPENCODE_TUI_PLUGIN_INSTALL_NAME,
+ OPENCODE_TUI_PLUGIN_SPEC, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET,
+ QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS,
};
fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> {
@@ -452,13 +455,21 @@ pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
)));
}
+ validate_tui_plugin_config(&dir)?;
let plugins_dir = dir.join("plugins");
fs::create_dir_all(&plugins_dir)?;
let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME);
fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET)?;
+ let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
+ fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET)?;
+ let tui_config_path = add_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC)?;
- Ok(OpenCodeInstallPaths { plugin_path })
+ Ok(OpenCodeInstallPaths {
+ plugin_path,
+ tui_plugin_path,
+ tui_config_path,
+ })
}
pub(crate) fn install_kilo() -> io::Result<KiloInstallPaths> {
@@ -801,14 +812,38 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
}
pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
- let plugin_path = opencode_dir()?
- .join("plugins")
- .join(OPENCODE_PLUGIN_INSTALL_NAME);
- let removed_plugin = remove_file_if_exists(&plugin_path)?;
+ let dir = opencode_dir()?;
+ let tui_config_path = tui_config_path(&dir);
+ let plugin_path = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
+ let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
+ let mut errors = Vec::new();
+ let updated_tui_config =
+ remove_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap_or_else(|err| {
+ errors.push(err.to_string());
+ false
+ });
+ let removed_plugin = remove_file_if_exists(&plugin_path).unwrap_or_else(|err| {
+ errors.push(format!("failed to remove {}: {err}", plugin_path.display()));
+ false
+ });
+ let removed_tui_plugin = remove_file_if_exists(&tui_plugin_path).unwrap_or_else(|err| {
+ errors.push(format!(
+ "failed to remove {}: {err}",
+ tui_plugin_path.display()
+ ));
+ false
+ });
+ if !errors.is_empty() {
+ return Err(io::Error::other(errors.join("; ")));
+ }
Ok(OpenCodeUninstallResult {
plugin_path,
+ tui_plugin_path,
+ tui_config_path,
removed_plugin,
+ removed_tui_plugin,
+ updated_tui_config,
})
}
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index 0081fcdb..6eaf7a39 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -2201,7 +2201,7 @@ fn install_droid_errors_when_config_dir_missing() {
}
#[test]
-fn install_opencode_writes_plugin_to_plugins_dir() {
+fn install_opencode_writes_server_and_tui_plugins() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
@@ -2210,7 +2210,6 @@ fn install_opencode_writes_plugin_to_plugins_dir() {
std::env::set_var("HOME", &home);
let installed = install_opencode().unwrap();
- let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap();
assert_eq!(
installed.plugin_path,
@@ -2218,30 +2217,126 @@ fn install_opencode_writes_plugin_to_plugins_dir() {
.join("plugins")
.join(OPENCODE_PLUGIN_INSTALL_NAME)
);
- assert_eq!(plugin_content, OPENCODE_PLUGIN_ASSET);
+ assert_eq!(
+ fs::read_to_string(&installed.plugin_path).unwrap(),
+ OPENCODE_PLUGIN_ASSET
+ );
+ assert_eq!(
+ installed.tui_plugin_path,
+ opencode_dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME)
+ );
+ assert_eq!(
+ fs::read_to_string(&installed.tui_plugin_path).unwrap(),
+ OPENCODE_TUI_PLUGIN_ASSET
+ );
+ assert_eq!(installed.tui_config_path, opencode_dir.join("tui.jsonc"));
+ let tui_config: Value =
+ serde_json::from_str(&fs::read_to_string(&installed.tui_config_path).unwrap()).unwrap();
+ assert_eq!(tui_config["plugin"], json!([OPENCODE_TUI_PLUGIN_SPEC]));
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
-fn uninstall_opencode_removes_plugin_when_present() {
+fn opencode_status_requires_the_tui_plugin_and_config_entry() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
- let opencode_dir = home.join(".config/opencode/plugins");
+ let opencode_dir = home.join(".config/opencode");
fs::create_dir_all(&opencode_dir).unwrap();
- fs::write(
- opencode_dir.join(OPENCODE_PLUGIN_INSTALL_NAME),
- OPENCODE_PLUGIN_ASSET,
- )
- .unwrap();
std::env::set_var("HOME", &home);
+ let installed = install_opencode().unwrap();
+ let status = || {
+ integration_status_at(
+ crate::api::schema::IntegrationTarget::Opencode,
+ installed.plugin_path.clone(),
+ OPENCODE_INTEGRATION_VERSION,
+ )
+ .state
+ };
+
+ assert_eq!(status(), IntegrationStatusKind::Current);
+ fs::remove_file(&installed.tui_plugin_path).unwrap();
+ assert_eq!(status(), IntegrationStatusKind::Outdated);
+ fs::write(&installed.tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET).unwrap();
+ super::opencode_config::remove_tui_plugin(&opencode_dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap();
+ assert_eq!(status(), IntegrationStatusKind::Outdated);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_opencode_removes_plugins_and_managed_tui_config_entry() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode");
+ fs::create_dir_all(&opencode_dir).unwrap();
+ std::env::set_var("HOME", &home);
+ let installed = install_opencode().unwrap();
let result = uninstall_opencode().unwrap();
assert!(result.removed_plugin);
+ assert!(result.removed_tui_plugin);
+ assert!(result.updated_tui_config);
assert!(!result.plugin_path.exists());
+ assert!(!result.tui_plugin_path.exists());
+ assert!(result.tui_config_path.exists());
+ let tui_config: Value =
+ serde_json::from_str(&fs::read_to_string(&result.tui_config_path).unwrap()).unwrap();
+ assert_eq!(tui_config, json!({}));
+ assert_eq!(installed.plugin_path, result.plugin_path);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn install_opencode_invalid_tui_config_does_not_write_plugins() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode");
+ fs::create_dir_all(&opencode_dir).unwrap();
+ fs::write(opencode_dir.join("tui.jsonc"), r#"{"plugin":{}}"#).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let err = install_opencode().unwrap_err().to_string();
+
+ assert!(err.contains("plugin list"));
+ assert!(!opencode_dir
+ .join("plugins")
+ .join(OPENCODE_PLUGIN_INSTALL_NAME)
+ .exists());
+ assert!(!opencode_dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME).exists());
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+}
+
+#[test]
+fn uninstall_opencode_removes_plugins_when_tui_config_is_invalid() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let opencode_dir = home.join(".config/opencode");
+ let plugins_dir = opencode_dir.join("plugins");
+ fs::create_dir_all(&plugins_dir).unwrap();
+ let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME);
+ let tui_plugin_path = opencode_dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET).unwrap();
+ fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET).unwrap();
+ fs::write(opencode_dir.join("tui.jsonc"), "{\"plugin\":").unwrap();
+ std::env::set_var("HOME", &home);
+
+ let err = uninstall_opencode().unwrap_err().to_string();
+
+ assert!(err.contains("failed to parse OpenCode TUI config"));
+ assert!(!plugin_path.exists());
+ assert!(!tui_plugin_path.exists());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
diff --git a/src/integration/types.rs b/src/integration/types.rs
index 09a235b0..c5c24267 100644
--- a/src/integration/types.rs
+++ b/src/integration/types.rs
@@ -42,6 +42,8 @@ pub(crate) struct DroidInstallPaths {
#[derive(Debug)]
pub(crate) struct OpenCodeInstallPaths {
pub plugin_path: PathBuf,
+ pub tui_plugin_path: PathBuf,
+ pub tui_config_path: PathBuf,
}
#[derive(Debug)]
@@ -225,7 +227,11 @@ pub(crate) struct DroidUninstallResult {
#[derive(Debug)]
pub(crate) struct OpenCodeUninstallResult {
pub plugin_path: PathBuf,
+ pub tui_plugin_path: PathBuf,
+ pub tui_config_path: PathBuf,
pub removed_plugin: bool,
+ pub removed_tui_plugin: bool,
+ pub updated_tui_config: bool,
}
#[derive(Debug)]