Skip to content
Open
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
26 changes: 26 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogWorktree } from "@tui/component/dialog-worktree"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
Expand Down Expand Up @@ -463,6 +464,31 @@ function App() {
local.agent.move(-1)
},
},
...(sync.data.vcs
? [
{
title: "Select workspace",
value: "workspace.select",
keybind: undefined as any,
suggested: route.data.type === "home",
slash: {
name: "workspace",
aliases: ["worktree", "sandbox"],
},
onSelect: () => {
dialog.replace(() => (
<DialogWorktree
current={kv.get("worktree_selection", "main") as string}
onSelect={(value: string) => {
kv.set("worktree_selection", value)
}}
/>
))
},
category: "Session",
},
]
: []),
{
title: "Connect provider",
value: "provider.connect",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "path"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
Expand Down Expand Up @@ -34,6 +35,8 @@ export function DialogSessionList() {

const sessions = createMemo(() => searchResults() ?? sync.data.session)

const primary = createMemo(() => sync.data.path.worktree)

const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
Expand All @@ -48,8 +51,10 @@ export function DialogSessionList() {
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
const worktree = primary() && x.directory !== primary() ? path.basename(x.directory) : undefined
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
description: worktree ? `[${worktree}]` : undefined,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
Expand Down
53 changes: 53 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import path from "path"
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"

const MAIN = "main"
const CREATE = "create"

export function DialogWorktree(props: {
current: string
onSelect: (value: string) => void
}) {
const sync = useSync()
const dialog = useDialog()

const branch = createMemo(() => sync.data.vcs?.branch)

const options = createMemo(() => {
const items = [
{
value: MAIN,
title: "Main workspace",
description: branch() ? `branch: ${branch()}` : undefined,
},
]
for (const sandbox of sync.data.sandboxes) {
items.push({
value: sandbox,
title: path.basename(sandbox),
description: sandbox,
})
}
items.push({
value: CREATE,
title: "Create new workspace",
description: "Create a new git worktree",
})
return items
})

return (
<DialogSelect
title="Select workspace"
current={props.current}
options={options()}
onSelect={(option) => {
props.onSelect(option.value)
dialog.clear()
}}
/>
)
}
31 changes: 27 additions & 4 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export type PromptProps = {
ref?: (ref: PromptRef) => void
hint?: JSX.Element
showPlaceholder?: boolean
worktree?: string
onWorktreeChange?: (value: string) => void
}

export type PromptRef = {
Expand Down Expand Up @@ -537,10 +539,30 @@ export function Prompt(props: PromptProps) {
promptModelWarning()
return
}
const worktreeSelection = !props.sessionID ? (props.worktree ?? "main") : "main"
let client = sdk.client
let directory: string | undefined
if (!props.sessionID && worktreeSelection !== "main") {
if (worktreeSelection === "create") {
const created = await sdk.client.worktree.create({}).then((x) => x.data).catch(() => undefined)
if (!created?.directory) {
toast.show({ variant: "error", message: "Failed to create workspace" })
return
}
directory = created.directory
client = sdk.createClient(directory)
toast.show({ variant: "info", message: `Creating workspace ${created.name}...`, duration: 3000 })
} else {
directory = worktreeSelection
client = sdk.createClient(directory)
}
props.onWorktreeChange?.("main")
}

const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
const sessionID = await client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
const messageID = Identifier.ascending("message")
Expand Down Expand Up @@ -570,7 +592,7 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current()

if (store.mode === "shell") {
sdk.client.session.shell({
client.session.shell({
sessionID,
agent: local.agent.current().name,
model: {
Expand All @@ -595,7 +617,7 @@ export function Prompt(props: PromptProps) {
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")

sdk.client.session.command({
client.session.command({
sessionID,
command: command.slice(1),
arguments: args,
Expand All @@ -611,7 +633,7 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
sdk.client.session
client.session
.prompt({
sessionID,
...selectedModel,
Expand Down Expand Up @@ -651,6 +673,7 @@ export function Prompt(props: PromptProps) {
route.navigate({
type: "session",
sessionID,
directory,
})
}, 50)
input.clear()
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/context/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type HomeRoute = {
export type SessionRoute = {
type: "session"
sessionID: string
directory?: string
initialPrompt?: PromptInfo
}

Expand Down
14 changes: 13 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer)
})

return { client: sdk, event: emitter, url: props.url }
return {
client: sdk,
event: emitter,
url: props.url,
createClient(directory: string) {
return createOpencodeClient({
baseUrl: props.url,
fetch: props.fetch,
headers: props.headers,
directory,
})
},
}
},
})
19 changes: 14 additions & 5 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpResource
}
formatter: FormatterStatus[]
sandboxes: string[]
vcs: VcsInfo | undefined
path: Path
}>({
Expand Down Expand Up @@ -98,6 +99,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp: {},
mcp_resource: {},
formatter: [],
sandboxes: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
})
Expand Down Expand Up @@ -318,6 +320,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}

case "project.updated": {
sdk.client.worktree.list().then((x) => setStore("sandboxes", x.data ?? []))
break
}

case "vcs.branch.updated": {
setStore("vcs", { branch: event.properties.branch })
break
Expand Down Expand Up @@ -395,6 +402,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
sdk.client.worktree.list().then((x) => setStore("sandboxes", x.data ?? [])),
]).then(() => {
setStore("status", "complete")
})
Expand Down Expand Up @@ -439,13 +447,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
async sync(sessionID: string, directory?: string) {
if (fullSyncedSessions.has(sessionID)) return
const client = directory ? sdk.createClient(directory) : sdk.client
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
client.session.get({ sessionID }, { throwOnError: true }),
client.session.messages({ sessionID, limit: 100 }),
client.session.todo({ sessionID }),
client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
Expand Down
25 changes: 25 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "path"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
Expand Down Expand Up @@ -34,6 +35,19 @@ export function Home() {
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
})

const worktree = () => kv.get("worktree_selection", "main") as string
const setWorktree = (value: string) => kv.set("worktree_selection", value)
const isGit = createMemo(() => !!sync.data.vcs)
const worktreeLabel = createMemo(() => {
const value = worktree()
if (value === "main") {
const branch = sync.data.vcs?.branch
return branch ? `main (${branch})` : "main"
}
if (value === "create") return "new workspace"
return path.basename(value)
})

const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
const showTips = createMemo(() => {
Expand Down Expand Up @@ -107,6 +121,8 @@ export function Home() {
promptRef.set(r)
}}
hint={Hint}
worktree={worktree()}
onWorktreeChange={setWorktree}
/>
</box>
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
Expand All @@ -119,6 +135,15 @@ export function Home() {
</box>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text>
<Show when={isGit()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={theme.text}>
<span style={{ fg: theme.accent }}>⊙ </span>
{worktreeLabel()}
</text>
<text fg={theme.textMuted}>/workspace</text>
</box>
</Show>
<box gap={1} flexDirection="row" flexShrink={0}>
<Show when={mcp()}>
<text fg={theme.text}>
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export function Session() {

createEffect(async () => {
await sync.session
.sync(route.sessionID)
.sync(route.sessionID, route.directory)
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,16 @@ function createWorkerFetch(client: RpcClient): typeof fetch {

function createEventSource(client: RpcClient): EventSource {
return {
on: (handler) => client.on<Event>("event", handler),
on: (handler) => {
const unsub1 = client.on<Event>("event", handler)
const unsub2 = client.on<{ directory?: string; payload: Event }>("global.event", (event) => {
handler(event.payload)
})
return () => {
unsub1()
unsub2()
}
},
}
}

Expand Down
Loading