diff options
| author | akbash <akbash@herdr.dev> | 2026-08-10 14:57:44 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-10 14:57:44 +0300 |
| commit | ccccda54c21f6ef1649a44bb098d7c498724404d (patch) | |
| tree | 86f28b85086ef36fd7ebc0f1d928b7606d76c487 /src/integration | |
| parent | 2e8cc9060d723a5ac32a2325e695fd8750ff60fe (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.rs | 46 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.js | 17 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.test.ts | 31 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-tui-session.js | 113 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-tui-session.test.ts | 181 | ||||
| -rw-r--r-- | src/integration/mod.rs | 6 | ||||
| -rw-r--r-- | src/integration/opencode_config.rs | 298 | ||||
| -rw-r--r-- | src/integration/registry.rs | 22 | ||||
| -rw-r--r-- | src/integration/targets.rs | 51 | ||||
| -rw-r--r-- | src/integration/tests.rs | 115 | ||||
| -rw-r--r-- | src/integration/types.rs | 6 |
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)] |
