From a57b97285c3308fbdcc167ab3da9ae1dd78767b6 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 23 Mar 2026 00:36:52 +0300 Subject: initial release terminal workspace manager for AI coding agents. single rust binary, runs inside your existing terminal. workspaces, tiled panes, intelligent agent state detection. https://herdr.dev --- tests/integration/ext/herdr-test.ts | 146 ++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/integration/ext/herdr-test.ts (limited to 'tests/integration/ext') diff --git a/tests/integration/ext/herdr-test.ts b/tests/integration/ext/herdr-test.ts new file mode 100644 index 00000000..4b6c17d5 --- /dev/null +++ b/tests/integration/ext/herdr-test.ts @@ -0,0 +1,146 @@ +/** + * Minimal tmux extension for herdr integration testing. + * + * Two actions: send and read. No wait, no pane creation, no discovery loops. + * Talks directly to a pane tagged @pi_name=herdr. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +function stripAnsi(text: string): string { + return text + .replace(/\x1b\].*?(?:\x07|\x1b\\)/g, "") + .replace(/\x1bPtmux;.*?\x1b\\/g, "") + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "") + .replace(/\x1b[^[\]P]/g, "") + .replace(/\r/g, ""); +} + +export default function (pi: ExtensionAPI) { + let herdrPaneId: string | null = null; + let windowId: string | null = null; + + pi.on("session_start", async () => { + if (!process.env.TMUX) return; + + try { + const winResult = await pi.exec("tmux", [ + "display-message", "-p", "-t", process.env.TMUX_PANE || "", + "#{window_id}", + ]); + if (winResult.code === 0) windowId = winResult.stdout.trim(); + + if (windowId) { + const listResult = await pi.exec("tmux", [ + "list-panes", "-t", windowId, + "-F", "#{pane_id}\t#{@pi_name}", + ]); + if (listResult.code === 0) { + for (const line of listResult.stdout.trim().split("\n")) { + const [paneId, name] = line.split("\t"); + if (name === "herdr") { + herdrPaneId = paneId; + break; + } + } + } + } + } catch {} + }); + + pi.registerTool({ + name: "herdr", + label: "herdr", + description: + "Interact with the herdr instance under test. " + + "Actions: send (send keys or text to herdr), read (capture herdr screen).", + parameters: Type.Object({ + action: StringEnum(["send", "read"] as const, { + description: "Action: send or read", + }), + keys: Type.Optional( + Type.String({ + description: + "Keys to send, space-separated (for send). Examples: C-s, Enter, n, v, Escape", + }), + ), + text: Type.Optional( + Type.String({ description: "Literal text to type (for send). Sent as-is." }), + ), + lines: Type.Optional( + Type.Number({ description: "Scrollback lines to capture (for read, default: 50)" }), + ), + }), + + async execute(_toolCallId, params) { + if (!herdrPaneId) { + throw new Error("herdr pane not found. Is herdr running in this tmux window?"); + } + + switch (params.action) { + case "send": { + if (!params.keys && !params.text) { + throw new Error("send requires 'keys' or 'text'"); + } + if (params.text) { + await pi.exec("tmux", [ + "send-keys", "-l", "-t", herdrPaneId, params.text, + ]); + } + if (params.keys) { + const keyArgs = params.keys.split(/\s+/).filter(Boolean); + await pi.exec("tmux", [ + "send-keys", "-t", herdrPaneId, ...keyArgs, + ]); + } + const desc = [ + params.text && `"${params.text}"`, + params.keys, + ].filter(Boolean).join(" + "); + return { + content: [{ type: "text", text: `Sent: ${desc}` }], + }; + } + + case "read": { + const lines = params.lines ?? 50; + const result = await pi.exec("tmux", [ + "capture-pane", "-t", herdrPaneId, "-p", "-S", `-${lines}`, + ]); + if (result.code !== 0) { + throw new Error(`capture-pane failed: ${result.stderr}`); + } + let output = stripAnsi(result.stdout); + output = output.replace(/\n+$/, "\n"); + return { + content: [{ type: "text", text: output }], + }; + } + + default: + throw new Error(`Unknown action: ${params.action}`); + } + }, + + renderCall(args, theme) { + const action = args.action || "?"; + let t = theme.fg("toolTitle", theme.bold("herdr ")); + t += theme.fg("accent", action); + if (args.text) t += theme.fg("dim", ` "${args.text}"`); + if (args.keys) t += theme.fg("dim", ` ${args.keys}`); + return new Text(t, 0, 0); + }, + + renderResult(result, _opts, theme) { + const c = result.content?.[0]; + const text = c?.type === "text" ? c.text : ""; + if (text.length > 200) { + return new Text(theme.fg("dim", text.slice(0, 200) + "…"), 0, 0); + } + return new Text(theme.fg("dim", text), 0, 0); + }, + }); +} -- cgit v1.2.3