opencode-multiplexer
Advanced tools
+1
-1
| { | ||
| "name": "opencode-multiplexer", | ||
| "version": "0.6.1", | ||
| "version": "0.7.0", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "description": "Multiplexer for opencode AI coding agent sessions", |
| import { describe, test, expect } from "bun:test" | ||
| import { relativeTime, highlightMatches } from "../views/helpers.js" | ||
| import { relativeTime, highlightMatches, filterFilesForCwd, findDisplayLineMatches, getSearchScrollOffset } from "../views/helpers.js" | ||
| import { deriveRepoName } from "../poller.js" | ||
@@ -147,1 +147,84 @@ import { getAllSessions, type DbSessionWithProject, NEEDS_INPUT_TOOLS } from "../db/reader.js" | ||
| }) | ||
| describe("filterFilesForCwd", () => { | ||
| test("keeps relative files and absolute files within cwd", () => { | ||
| expect(filterFilesForCwd([ | ||
| "src/app.ts", | ||
| "/repo/src/views/conversation.tsx", | ||
| "/other/file.ts", | ||
| ], "/repo")).toEqual([ | ||
| "src/app.ts", | ||
| "/repo/src/views/conversation.tsx", | ||
| ]) | ||
| }) | ||
| test("does not treat sibling directories as inside cwd", () => { | ||
| expect(filterFilesForCwd([ | ||
| "/repo-other/file.ts", | ||
| "/repo/file.ts", | ||
| ], "/repo")).toEqual([ | ||
| "/repo/file.ts", | ||
| ]) | ||
| }) | ||
| }) | ||
| describe("findDisplayLineMatches", () => { | ||
| const lines = [ | ||
| { kind: "role-header", role: "user", time: "10:00" }, | ||
| { kind: "text", text: "Hello world" }, | ||
| { kind: "tool", icon: "✓", color: "green", name: "bash", input: "python -V" }, | ||
| { kind: "question", header: "Confirm", question: "Search the visible line?", status: "running", options: [], custom: false }, | ||
| { kind: "thinking", text: "Working through the plan" }, | ||
| { kind: "spacer" }, | ||
| ] as const | ||
| test("matches visible text-bearing lines only", () => { | ||
| expect(findDisplayLineMatches(lines as any, "world")).toEqual([1]) | ||
| expect(findDisplayLineMatches(lines as any, "python")).toEqual([2]) | ||
| expect(findDisplayLineMatches(lines as any, "visible line")).toEqual([3]) | ||
| expect(findDisplayLineMatches(lines as any, "working")).toEqual([4]) | ||
| }) | ||
| test("is case-insensitive and ignores non-searchable rows", () => { | ||
| expect(findDisplayLineMatches(lines as any, "HELLO")).toEqual([1]) | ||
| expect(findDisplayLineMatches(lines as any, "10:00")).toEqual([]) | ||
| }) | ||
| test("uses only the visible tool detail when title and input both exist", () => { | ||
| const toolLine = [{ | ||
| kind: "tool", | ||
| icon: "✓", | ||
| color: "green", | ||
| name: "bash", | ||
| title: "visible title", | ||
| input: "hidden input", | ||
| }] as const | ||
| expect(findDisplayLineMatches(toolLine as any, "visible title")).toEqual([0]) | ||
| expect(findDisplayLineMatches(toolLine as any, "hidden input")).toEqual([]) | ||
| }) | ||
| test("matches text after stripping ansi styling", () => { | ||
| const styledLine = [{ kind: "text", text: "\x1b[1mHello\x1b[22m world" }] as const | ||
| expect(findDisplayLineMatches(styledLine as any, "hello")).toEqual([0]) | ||
| }) | ||
| test("returns no matches for blank queries", () => { | ||
| expect(findDisplayLineMatches(lines as any, "")).toEqual([]) | ||
| expect(findDisplayLineMatches(lines as any, " ")).toEqual([]) | ||
| }) | ||
| }) | ||
| describe("getSearchScrollOffset", () => { | ||
| test("keeps a matched line visible near the top of the viewport", () => { | ||
| expect(getSearchScrollOffset(100, 10, 30)).toBe(60) | ||
| }) | ||
| test("clamps near the bottom when the match is already in the latest rows", () => { | ||
| expect(getSearchScrollOffset(100, 10, 95)).toBe(0) | ||
| }) | ||
| test("clamps near the top when the first rows are matched", () => { | ||
| expect(getSearchScrollOffset(100, 10, 0)).toBe(90) | ||
| }) | ||
| }) |
| import { describe, test, expect, beforeEach, afterEach } from "bun:test" | ||
| import { execSync } from "child_process" | ||
| import { existsSync, mkdirSync, rmSync } from "fs" | ||
| import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs" | ||
| import { join } from "path" | ||
@@ -8,2 +8,3 @@ import { tmpdir } from "os" | ||
| import { getSessionModifiedFiles } from "../db/reader.js" | ||
| import { detectProjectVenv } from "../views/worktree.js" | ||
@@ -95,1 +96,40 @@ // Create a temporary git repo for testing | ||
| }) | ||
| describe("detectProjectVenv", () => { | ||
| test("prefers project root .venv over worktree cwd", () => { | ||
| const repoDir = join(TEST_DIR, "repo-with-venv") | ||
| const worktreeDir = join(repoDir, ".worktrees", "feature-branch") | ||
| const rootPython = join(repoDir, ".venv", "bin", "python") | ||
| const worktreePython = join(worktreeDir, ".venv", "bin", "python") | ||
| mkdirSync(join(repoDir, ".venv", "bin"), { recursive: true }) | ||
| mkdirSync(join(worktreeDir, ".venv", "bin"), { recursive: true }) | ||
| writeFileSync(rootPython, "") | ||
| writeFileSync(worktreePython, "") | ||
| const venv = detectProjectVenv(repoDir) | ||
| expect(venv?.root).toBe(join(repoDir, ".venv")) | ||
| expect(venv?.binDir).toBe(join(repoDir, ".venv", "bin")) | ||
| }) | ||
| test("falls back to project root venv when .venv is absent", () => { | ||
| const repoDir = join(TEST_DIR, "repo-with-venv-fallback") | ||
| const pythonPath = join(repoDir, "venv", "bin", "python") | ||
| mkdirSync(join(repoDir, "venv", "bin"), { recursive: true }) | ||
| writeFileSync(pythonPath, "") | ||
| const venv = detectProjectVenv(repoDir) | ||
| expect(venv?.root).toBe(join(repoDir, "venv")) | ||
| expect(venv?.binDir).toBe(join(repoDir, "venv", "bin")) | ||
| }) | ||
| test("returns null when no supported project-root virtualenv exists", () => { | ||
| const repoDir = join(TEST_DIR, "repo-without-venv") | ||
| mkdirSync(repoDir, { recursive: true }) | ||
| expect(detectProjectVenv(repoDir)).toBeNull() | ||
| }) | ||
| }) |
+42
-0
| import stripAnsi from "strip-ansi" | ||
| import { relative as pathRelative } from "path" | ||
| import type { SessionStatus } from "../store.js" | ||
| import type { DisplayLine } from "./display-lines.js" | ||
@@ -87,1 +89,41 @@ /** | ||
| } | ||
| export function filterFilesForCwd(files: string[], cwd: string): string[] { | ||
| return files.filter((file) => { | ||
| if (!file.startsWith("/")) return true | ||
| const relativePath = pathRelative(cwd, file) | ||
| return relativePath === "" || (!relativePath.startsWith("..") && relativePath !== "..") | ||
| }) | ||
| } | ||
| function getDisplayLineSearchText(line: DisplayLine): string { | ||
| switch (line.kind) { | ||
| case "thinking": | ||
| case "text": | ||
| return line.text | ||
| case "tool": | ||
| return [line.name, line.title || line.input].filter(Boolean).join(" ") | ||
| case "question": | ||
| return [line.header, line.question].filter(Boolean).join(" ") | ||
| default: | ||
| return "" | ||
| } | ||
| } | ||
| export function findDisplayLineMatches(lines: DisplayLine[], query: string): number[] { | ||
| const trimmed = query.trim() | ||
| if (!trimmed) return [] | ||
| const lowerQuery = trimmed.toLowerCase() | ||
| return lines.flatMap((line, index) => | ||
| stripAnsi(getDisplayLineSearchText(line)).toLowerCase().includes(lowerQuery) ? [index] : [] | ||
| ) | ||
| } | ||
| export function getSearchScrollOffset(totalLines: number, msgAreaHeight: number, lineIndex: number): number { | ||
| const safeHeight = Math.max(1, msgAreaHeight) | ||
| const maxStart = Math.max(0, totalLines - safeHeight) | ||
| const targetStart = Math.max(0, Math.min(lineIndex, maxStart)) | ||
| return Math.max(0, totalLines - safeHeight - targetStart) | ||
| } |
@@ -6,2 +6,3 @@ import React from "react" | ||
| import { existsSync } from "fs" | ||
| import { join } from "path" | ||
| import { createOpencodeClient } from "@opencode-ai/sdk" | ||
@@ -72,2 +73,13 @@ import { useStore } from "../store.js" | ||
| export function detectProjectVenv(projectRoot: string): { root: string; binDir: string } | null { | ||
| for (const dirName of [".venv", "venv"]) { | ||
| const root = join(projectRoot, dirName) | ||
| const binDir = join(root, "bin") | ||
| if (existsSync(join(binDir, "python"))) { | ||
| return { root, binDir } | ||
| } | ||
| } | ||
| return null | ||
| } | ||
| export function Worktree() { | ||
@@ -117,3 +129,3 @@ const navigate = useStore((s) => s.navigate) | ||
| const doSpawn = React.useCallback(async (cwd: string) => { | ||
| const doSpawn = React.useCallback(async (cwd: string, projectRoot: string) => { | ||
| setStep("spawning") | ||
@@ -124,2 +136,10 @@ setErrorMsg("") | ||
| const port = await findNextPort() | ||
| const projectVenv = detectProjectVenv(projectRoot) | ||
| const env = projectVenv | ||
| ? { | ||
| ...process.env, | ||
| PATH: `${projectVenv.binDir}:${process.env.PATH ?? ""}`, | ||
| VIRTUAL_ENV: projectVenv.root, | ||
| } | ||
| : undefined | ||
| const proc = spawnProcess("opencode", ["serve", "--port", String(port)], { | ||
@@ -129,2 +149,3 @@ cwd, | ||
| stdio: "ignore", | ||
| env, | ||
| }) | ||
@@ -165,3 +186,3 @@ proc.unref() | ||
| if (!name.trim()) { | ||
| void doSpawn(repoDir) | ||
| void doSpawn(repoDir, repoDir) | ||
| return | ||
@@ -171,3 +192,3 @@ } | ||
| const worktreeDir = createWorktree(repoDir, name.trim()) | ||
| void doSpawn(worktreeDir) | ||
| void doSpawn(worktreeDir, repoDir) | ||
| } catch (e) { | ||
@@ -174,0 +195,0 @@ setErrorMsg(`Failed to create worktree: ${String(e)}`) |
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 7 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 5 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
5210094
0.18%7048
3.28%34
6.25%