Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/treebeard/opencode-logo-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 11 additions & 1 deletion packages/treebeard/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -210,6 +211,9 @@ const mainviewRPC = BrowserView.defineRPC<TreebeardRPC>({
'launch:ghostty': ({ worktreePath }) => {
launchGhostty(worktreePath)
},
'launch:opencode': ({ worktreePath }) => {
launchOpencode(worktreePath)
},
'launch:url': async ({ url }) => {
if (Utils.openExternal(url)) {
return { success: true }
Expand All @@ -227,6 +231,12 @@ const mainviewRPC = BrowserView.defineRPC<TreebeardRPC>({
'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(),
Expand Down
33 changes: 33 additions & 0 deletions packages/treebeard/src/bun/services/launcher.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,6 +23,38 @@ export async function launchGhostty(worktreePath: string): Promise<void> {
})
}

export async function launchOpencode(worktreePath: string): Promise<void> {
// 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<void> {
const proc = Bun.spawn(['/usr/bin/open', url], {
stdout: 'pipe',
Expand Down
6 changes: 5 additions & 1 deletion packages/treebeard/src/components/LaunchButtons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
}))
Expand All @@ -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', () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/treebeard/src/components/LaunchButtons.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,6 +14,11 @@ interface LaunchButtonsProps {

export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps) {
const ide = IDE_REGISTRY[defaultIde]
const [opencodePath, setOpencodePath] = useState<string | null>(null)

useEffect(() => {
rpc().request['system:opencodePath']({}).then(setOpencodePath).catch(() => setOpencodePath(null))
}, [])

const handleIde = async () => {
await rpc().request['launch:ide']({ ideId: defaultIde, worktreePath })
Expand All @@ -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 (
<Group gap={4}>
<Tooltip label={`Open in ${ide.label}`}>
Expand All @@ -33,6 +44,13 @@ export function LaunchButtons({ worktreePath, defaultIde }: LaunchButtonsProps)
<IconGhost size={16} />
</ActionIcon>
</Tooltip>
{opencodePath && (
<Tooltip label="Open in OpenCode">
<ActionIcon variant="subtle" size="sm" onClick={handleOpencode}>
<OpencodeIcon size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
)
}
24 changes: 24 additions & 0 deletions packages/treebeard/src/components/OpencodeIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
interface OpencodeIconProps {
size?: number
}

export function OpencodeIcon({ size = 16 }: OpencodeIconProps) {
return (
<svg width={size} height={size} viewBox="0 0 240 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0)">
<mask id="mask0" style={{ maskType: 'luminance' }} maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
<path d="M240 0H0V300H240V0Z" fill="white" />
</mask>
<g mask="url(#mask0)">
<path d="M180 240H60V120H180V240Z" fill="#4B4646" />
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC" />
</g>
</g>
<defs>
<clipPath id="clip0">
<rect width="240" height="300" fill="white" />
</clipPath>
</defs>
</svg>
)
}
2 changes: 1 addition & 1 deletion packages/treebeard/src/components/WorktreeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ export function WorktreeCard({
</Group>

<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
<LaunchButtons worktreePath={worktree.path} defaultIde={defaultIde} />
{!worktree.isMain && (
<Tooltip label="Delete worktree">
<ActionIcon
Expand All @@ -130,6 +129,7 @@ export function WorktreeCard({
</ActionIcon>
</Tooltip>
)}
<LaunchButtons worktreePath={worktree.path} defaultIde={defaultIde} />
</Group>
</>
)}
Expand Down
8 changes: 8 additions & 0 deletions packages/treebeard/src/shared/rpc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -98,6 +102,10 @@ export type TreebeardRPC = {
params: Record<string, never>
response: string
}
'system:opencodePath': {
params: Record<string, never>
response: string | null
}
'dialog:openDirectory': {
params: Record<string, never>
response: string | null
Expand Down
Loading