From 26b3df13d904fa06723f0d117f2822d2e845718e Mon Sep 17 00:00:00 2001 From: Martin Laporte Date: Tue, 7 Apr 2026 08:29:18 +0200 Subject: [PATCH 1/2] feat: add OpenCode quick action button to worktree cards --- packages/treebeard/opencode-logo-dark.svg | 1 + packages/treebeard/src/bun/index.ts | 12 ++++++- .../treebeard/src/bun/services/launcher.ts | 33 +++++++++++++++++++ .../src/components/LaunchButtons.tsx | 18 ++++++++++ .../treebeard/src/components/OpencodeIcon.tsx | 24 ++++++++++++++ .../treebeard/src/components/WorktreeCard.tsx | 2 +- packages/treebeard/src/shared/rpc-types.ts | 8 +++++ 7 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 packages/treebeard/opencode-logo-dark.svg create mode 100644 packages/treebeard/src/components/OpencodeIcon.tsx diff --git a/packages/treebeard/opencode-logo-dark.svg b/packages/treebeard/opencode-logo-dark.svg new file mode 100644 index 0000000..b79c733 --- /dev/null +++ b/packages/treebeard/opencode-logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/treebeard/src/bun/index.ts b/packages/treebeard/src/bun/index.ts index 0c57907..38050d1 100644 --- a/packages/treebeard/src/bun/index.ts +++ b/packages/treebeard/src/bun/index.ts @@ -18,7 +18,8 @@ import { } from './services/git' import { getPRForBranch } from './services/github' import { getJiraIssue, getMyJiraIssues } from './services/jira' -import { launchIde, launchGhostty, launchURL } from './services/launcher' +import { launchIde, launchGhostty, launchOpencode, launchURL } from './services/launcher' +import { getShellEnv } from './services/shell-env' import type { TreebeardRPC } from '../shared/rpc-types' import type { AppConfig, DependencyStatus } from '../shared/types' @@ -210,6 +211,9 @@ const mainviewRPC = BrowserView.defineRPC({ 'launch:ghostty': ({ worktreePath }) => { launchGhostty(worktreePath) }, + 'launch:opencode': ({ worktreePath }) => { + launchOpencode(worktreePath) + }, 'launch:url': async ({ url }) => { if (Utils.openExternal(url)) { return { success: true } @@ -227,6 +231,12 @@ const mainviewRPC = BrowserView.defineRPC({ 'system:homedir': () => { return os.homedir() }, + 'system:opencodePath': async () => { + const env = await getShellEnv() + const proc = Bun.spawn(['which', 'opencode'], { stdout: 'pipe', stderr: 'ignore', env }) + const result = (await new Response(proc.stdout).text()).trim() + return result || null + }, 'dialog:openDirectory': async () => { const paths = await Utils.openFileDialog({ startingFolder: os.homedir(), diff --git a/packages/treebeard/src/bun/services/launcher.ts b/packages/treebeard/src/bun/services/launcher.ts index b5bf865..f46deae 100644 --- a/packages/treebeard/src/bun/services/launcher.ts +++ b/packages/treebeard/src/bun/services/launcher.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { getShellEnv } from './shell-env' import { IDE_REGISTRY } from '../../shared/ide-registry' import type { IdeId } from '../../shared/types' @@ -22,6 +23,38 @@ export async function launchGhostty(worktreePath: string): Promise { }) } +export async function launchOpencode(worktreePath: string): Promise { + // Use AppleScript to switch to an existing Ghostty tab for this worktree, + // or open a new tab running opencode if none exists. + // Resolve the opencode binary path using the login shell env so it works + // regardless of where opencode is installed. + const env = await getShellEnv() + const whichProc = Bun.spawn(['which', 'opencode'], { stdout: 'pipe', stderr: 'ignore', env }) + const opencodePath = (await new Response(whichProc.stdout).text()).trim() + if (!opencodePath) return + const tabTitle = path.basename(worktreePath) + const script = ` +tell application "Ghostty" + set targetPath to "${worktreePath}" + set targetWindow to window 1 + repeat with t in every tab of targetWindow + set term to focused terminal of t + if working directory of term is targetPath then + select tab t + focus term + return + end if + end repeat + set cfg to new surface configuration + set initial working directory of cfg to targetPath + set initial input of cfg to "${opencodePath}\n" + set newTab to new tab in targetWindow with configuration cfg + perform action "set_tab_title:${tabTitle}" on (focused terminal of newTab) +end tell +` + Bun.spawn(['/usr/bin/osascript', '-e', script], { stdout: 'ignore', stderr: 'ignore' }) +} + export async function launchURL(url: string): Promise { const proc = Bun.spawn(['/usr/bin/open', url], { stdout: 'pipe', diff --git a/packages/treebeard/src/components/LaunchButtons.tsx b/packages/treebeard/src/components/LaunchButtons.tsx index 6920785..7837239 100644 --- a/packages/treebeard/src/components/LaunchButtons.tsx +++ b/packages/treebeard/src/components/LaunchButtons.tsx @@ -1,6 +1,8 @@ +import { useEffect, useState } from 'react' import { ActionIcon, Group, Tooltip } from '@mantine/core' import { IconGhost } from '@tabler/icons-react' import { IdeIcon } from './IdeIcon' +import { OpencodeIcon } from './OpencodeIcon' import { IDE_REGISTRY } from '../shared/ide-registry' import { rpc } from '../rpc' import type { IdeId } from '../shared/types' @@ -12,6 +14,11 @@ interface LaunchButtonsProps { export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) { const ide = IDE_REGISTRY[defaultIde] + const [opencodePath, setOpencodePath] = useState(null) + + useEffect(() => { + rpc().request['system:opencodePath']({}).then(setOpencodePath).catch(() => setOpencodePath(null)) + }, []) const handleIde = async () => { await rpc().request['launch:ide']({ ideId: defaultIde, worktreePath }) @@ -21,6 +28,10 @@ export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) await rpc().request['launch:ghostty']({ worktreePath }) } + const handleOpencode = async () => { + await rpc().request['launch:opencode']({ worktreePath }) + } + return ( @@ -33,6 +44,13 @@ export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) + {opencodePath && ( + + + + + + )} ) } diff --git a/packages/treebeard/src/components/OpencodeIcon.tsx b/packages/treebeard/src/components/OpencodeIcon.tsx new file mode 100644 index 0000000..e4f5029 --- /dev/null +++ b/packages/treebeard/src/components/OpencodeIcon.tsx @@ -0,0 +1,24 @@ +interface OpencodeIconProps { + size?: number +} + +export function OpencodeIcon({ size = 16 }: OpencodeIconProps) { + return ( + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/treebeard/src/components/WorktreeCard.tsx b/packages/treebeard/src/components/WorktreeCard.tsx index 4808ec8..3d32571 100644 --- a/packages/treebeard/src/components/WorktreeCard.tsx +++ b/packages/treebeard/src/components/WorktreeCard.tsx @@ -117,7 +117,6 @@ export function WorktreeCard({ - {!worktree.isMain && ( )} + )} diff --git a/packages/treebeard/src/shared/rpc-types.ts b/packages/treebeard/src/shared/rpc-types.ts index 01c9f54..5a5a3d5 100644 --- a/packages/treebeard/src/shared/rpc-types.ts +++ b/packages/treebeard/src/shared/rpc-types.ts @@ -90,6 +90,10 @@ export type TreebeardRPC = { params: { worktreePath: string } response: void } + 'launch:opencode': { + params: { worktreePath: string } + response: void + } 'launch:url': { params: { url: string } response: { success: boolean; error?: string } @@ -98,6 +102,10 @@ export type TreebeardRPC = { params: Record response: string } + 'system:opencodePath': { + params: Record + response: string | null + } 'dialog:openDirectory': { params: Record response: string | null From 0c8dbb1d86068b5f572f34dadd03bc6123eb000f Mon Sep 17 00:00:00 2001 From: Martin Laporte Date: Tue, 7 Apr 2026 09:06:32 +0200 Subject: [PATCH 2/2] fix: add system:opencodePath mock to LaunchButtons tests --- packages/treebeard/src/components/LaunchButtons.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/treebeard/src/components/LaunchButtons.test.tsx b/packages/treebeard/src/components/LaunchButtons.test.tsx index 7f14434..6300944 100644 --- a/packages/treebeard/src/components/LaunchButtons.test.tsx +++ b/packages/treebeard/src/components/LaunchButtons.test.tsx @@ -5,12 +5,14 @@ import { renderWithMantine } from '../test/render' const launchIdeRequest = vi.fn() const launchGhosttyRequest = vi.fn() +const opencodePathRequest = vi.fn() vi.mock('../rpc', () => ({ rpc: () => ({ request: { 'launch:ide': launchIdeRequest, - 'launch:ghostty': launchGhosttyRequest + 'launch:ghostty': launchGhosttyRequest, + 'system:opencodePath': opencodePathRequest } }) })) @@ -19,8 +21,10 @@ describe('LaunchButtons', () => { beforeEach(() => { launchIdeRequest.mockReset() launchGhosttyRequest.mockReset() + opencodePathRequest.mockReset() launchIdeRequest.mockResolvedValue(undefined) launchGhosttyRequest.mockResolvedValue(undefined) + opencodePathRequest.mockResolvedValue(null) }) it('launches configured IDE and Ghostty for the selected worktree', () => {