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.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', () => {
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