Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3aa943dd | ||
|
|
9be68a9fa4 | ||
|
|
2a0c9da40b | ||
|
|
81f71c9b30 | ||
|
|
baa204193c | ||
|
|
aeece6166b | ||
|
|
0d7e62a532 | ||
|
|
41aa254db4 | ||
|
|
d178d8249f | ||
|
|
e6f5214779 | ||
|
|
84f60d97a0 | ||
|
|
cbf4b68fee | ||
|
|
992f4f794a | ||
|
|
bd4527b4f2 | ||
|
|
0c2b5b2c39 | ||
|
|
f4a9fe29a3 | ||
|
|
5a0bfa7061 | ||
|
|
1ac1a0287c | ||
|
|
8e09e8c612 | ||
|
|
009d77c9d8 | ||
|
|
f3cf519d98 | ||
|
|
645c15351b | ||
|
|
f63a2a2636 |
@@ -1,3 +1,4 @@
|
|||||||
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { expect, type Locator, type Page } from "@playwright/test"
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
@@ -361,6 +362,30 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveSlug(slug: string) {
|
||||||
|
const directory = base64Decode(slug)
|
||||||
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
|
const resolved = await resolveDirectory(directory)
|
||||||
|
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitDir(page: Page, directory: string) {
|
||||||
|
const target = await resolveDirectory(directory)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
return resolveSlug(slug)
|
||||||
|
.then((item) => item.directory)
|
||||||
|
.catch(() => "")
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(target)
|
||||||
|
return { directory: target, slug: base64Encode(target) }
|
||||||
|
}
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
return match?.[1]
|
return match?.[1]
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
import {
|
||||||
|
defocus,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestProject,
|
||||||
|
openSidebar,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
waitDir,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
@@ -100,11 +108,8 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await expect(btn).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await btn.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
// A new workspace can be discovered via a transient slug before the route and sidebar
|
|
||||||
// settle to the canonical workspace path on Windows, so interact with either and assert
|
|
||||||
// against the resolved workspace slug.
|
|
||||||
await waitSlug(page)
|
await waitSlug(page)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
await waitDir(page, space)
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
// Create a session by sending a prompt
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
@@ -132,6 +137,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await expect(rootButton).toBeVisible()
|
await expect(rootButton).toBeVisible()
|
||||||
await rootButton.click()
|
await rootButton.click()
|
||||||
|
|
||||||
|
await waitDir(page, space)
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
function item(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
const row = page.locator(item(space)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await row.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -27,29 +34,30 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const slug = await waitSlug(page, [root, ...seen])
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
const directory = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
|
||||||
return { slug, directory }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openWorkspaceNewSession(page: Page, slug: string) {
|
|
||||||
await waitWorkspaceReady(page, slug)
|
|
||||||
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
await item.hover()
|
|
||||||
|
|
||||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await button.click({ force: true })
|
|
||||||
|
|
||||||
const next = await waitSlug(page)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
|
||||||
const next = await openWorkspaceNewSession(page, slug)
|
await waitWorkspaceReady(page, space)
|
||||||
|
|
||||||
|
const row = page.locator(item(space)).first()
|
||||||
|
await row.hover()
|
||||||
|
|
||||||
|
const next = page.locator(button(space)).first()
|
||||||
|
await expect(next).toBeVisible()
|
||||||
|
await next.click({ force: true })
|
||||||
|
|
||||||
|
return waitDir(page, space.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSessionFromWorkspace(
|
||||||
|
page: Page,
|
||||||
|
space: { slug: string; raw: string; directory: string },
|
||||||
|
text: string,
|
||||||
|
) {
|
||||||
|
const next = await openWorkspaceNewSession(page, space)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
@@ -60,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
|||||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
await prompt.press("Enter")
|
await prompt.press("Enter")
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
await waitDir(page, next.directory)
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||||
return { sessionID, slug: next }
|
return { sessionID, slug: next.slug }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sessionDirectory(directory: string, sessionID: string) {
|
async function sessionDirectory(directory: string, sessionID: string) {
|
||||||
@@ -87,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
|
|||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const first = await createWorkspace(page, root, [])
|
||||||
trackDirectory(first.directory)
|
trackDirectory(first.directory)
|
||||||
await waitWorkspaceReady(page, first.slug)
|
await waitWorkspaceReady(page, first)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const second = await createWorkspace(page, root, [first.slug])
|
||||||
trackDirectory(second.directory)
|
trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second.slug)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||||
trackSession(firstSession.sessionID, first.directory)
|
trackSession(firstSession.sessionID, first.directory)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
@@ -13,8 +12,10 @@ import {
|
|||||||
confirmDialog,
|
confirmDialog,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
|
resolveSlug,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
slugFromUrl,
|
slugFromUrl,
|
||||||
|
waitDir,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
@@ -27,15 +28,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const slug = await waitSlug(page, [rootSlug])
|
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||||
const dir = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
@@ -47,7 +48,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
return { rootSlug, slug, directory: dir }
|
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||||
@@ -79,15 +80,15 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const workspaceSlug = await waitSlug(page, [slug])
|
const next = await resolveSlug(await waitSlug(page, [slug]))
|
||||||
const workspaceDir = base64Decode(workspaceSlug)
|
await waitDir(page, next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
@@ -99,9 +100,9 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
await cleanupTestProject(workspaceDir)
|
await cleanupTestProject(next.directory)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
|||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const activeDir = base64Decode(slugFromUrl(page.url()))
|
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
||||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -331,9 +332,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
for (const _ of [0, 1]) {
|
for (const _ of [0, 1]) {
|
||||||
const prev = slugFromUrl(page.url())
|
const prev = slugFromUrl(page.url())
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const slug = await waitSlug(page, [rootSlug, prev])
|
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||||
const dir = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
workspaces.push({ slug, directory: dir })
|
workspaces.push(next)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import type { Locator, Page } from "@playwright/test"
|
import type { Locator, Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
|
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
|
||||||
import {
|
import {
|
||||||
promptAgentSelector,
|
promptAgentSelector,
|
||||||
promptModelSelector,
|
promptModelSelector,
|
||||||
@@ -224,10 +223,9 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const slug = await waitSlug(page, [root, ...seen])
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
const directory = base64Decode(slug)
|
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
return next
|
||||||
return { slug, directory }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitWorkspace(page: Page, slug: string) {
|
async function waitWorkspace(page: Page, slug: string) {
|
||||||
@@ -257,8 +255,8 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
|||||||
await expect(button).toBeVisible()
|
await expect(button).toBeVisible()
|
||||||
await button.click({ force: true })
|
await button.click({ force: true })
|
||||||
|
|
||||||
const next = await waitSlug(page)
|
const next = await resolveSlug(await waitSlug(page))
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
return currentDir(page)
|
return currentDir(page)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-26
@@ -46,21 +46,13 @@ import Layout from "@/pages/layout"
|
|||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
const HomeRoute = () => (
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SessionRoute = () => (
|
const SessionRoute = () => (
|
||||||
<SessionProviders>
|
<SessionProviders>
|
||||||
<Suspense fallback={<Loading />}>
|
<Session />
|
||||||
<Session />
|
|
||||||
</Suspense>
|
|
||||||
</SessionProviders>
|
</SessionProviders>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,8 +116,10 @@ function SessionProviders(props: ParentProps) {
|
|||||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<AppShellProviders>
|
<AppShellProviders>
|
||||||
{props.appChildren}
|
<Suspense fallback={<Loading />}>
|
||||||
{props.children}
|
{props.appChildren}
|
||||||
|
{props.children}
|
||||||
|
</Suspense>
|
||||||
</AppShellProviders>
|
</AppShellProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -265,6 +259,15 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ServerKey(props: ParentProps) {
|
||||||
|
const server = useServer()
|
||||||
|
return (
|
||||||
|
<Show when={server.key} keyed>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppInterface(props: {
|
export function AppInterface(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
@@ -275,20 +278,22 @@ export function AppInterface(props: {
|
|||||||
return (
|
return (
|
||||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
<GlobalSDKProvider>
|
<ServerKey>
|
||||||
<GlobalSyncProvider>
|
<GlobalSDKProvider>
|
||||||
<Dynamic
|
<GlobalSyncProvider>
|
||||||
component={props.router ?? Router}
|
<Dynamic
|
||||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
component={props.router ?? Router}
|
||||||
>
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
<Route path="/" component={HomeRoute} />
|
>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/" component={HomeRoute} />
|
||||||
<Route path="/" component={SessionIndexRoute} />
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route path="/" component={SessionIndexRoute} />
|
||||||
</Route>
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
</Dynamic>
|
</Route>
|
||||||
</GlobalSyncProvider>
|
</Dynamic>
|
||||||
</GlobalSDKProvider>
|
</GlobalSyncProvider>
|
||||||
|
</GlobalSDKProvider>
|
||||||
|
</ServerKey>
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { Link } from "@/components/link"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { DialogSelectModel } from "./dialog-select-model"
|
import { DialogSelectModel } from "./dialog-select-model"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const alive = { value: true }
|
const alive = { value: true }
|
||||||
@@ -49,13 +47,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error",
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "method.select"; index: number }
|
| { type: "method.select"; index: number }
|
||||||
| { type: "method.reset" }
|
| { type: "method.reset" }
|
||||||
|
| { type: "auth.prompt" }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
@@ -77,6 +76,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (action.type === "auth.prompt") {
|
||||||
|
draft.state = "prompt"
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action.type === "auth.pending") {
|
if (action.type === "auth.pending") {
|
||||||
draft.state = "pending"
|
draft.state = "pending"
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
@@ -120,7 +124,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectMethod(index: number) {
|
async function selectMethod(index: number, inputs?: Record<string, string>) {
|
||||||
if (timer.current !== undefined) {
|
if (timer.current !== undefined) {
|
||||||
clearTimeout(timer.current)
|
clearTimeout(timer.current)
|
||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
@@ -130,6 +134,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
dispatch({ type: "method.select", index })
|
dispatch({ type: "method.select", index })
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
|
if (method.prompts?.length && !inputs) {
|
||||||
|
dispatch({ type: "auth.prompt" })
|
||||||
|
return
|
||||||
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await globalSDK.client.provider.oauth
|
await globalSDK.client.provider.oauth
|
||||||
@@ -137,6 +145,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
{
|
{
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: index,
|
method: index,
|
||||||
|
inputs,
|
||||||
},
|
},
|
||||||
{ throwOnError: true },
|
{ throwOnError: true },
|
||||||
)
|
)
|
||||||
@@ -163,6 +172,122 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OAuthPromptsView() {
|
||||||
|
const [formStore, setFormStore] = createStore({
|
||||||
|
value: {} as Record<string, string>,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const prompts = createMemo(() => method()?.prompts ?? [])
|
||||||
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
|
if (!prompt.when) return true
|
||||||
|
const actual = value[prompt.when.key]
|
||||||
|
if (actual === undefined) return false
|
||||||
|
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
|
||||||
|
}
|
||||||
|
const current = createMemo(() => {
|
||||||
|
const all = prompts()
|
||||||
|
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
|
||||||
|
if (index === -1) return
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
prompt: all[index],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const valid = createMemo(() => {
|
||||||
|
const item = current()
|
||||||
|
if (!item || item.prompt.type !== "text") return false
|
||||||
|
const value = formStore.value[item.prompt.key] ?? ""
|
||||||
|
return value.trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
async function next(index: number, value: Record<string, string>) {
|
||||||
|
if (store.methodIndex === undefined) return
|
||||||
|
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
|
||||||
|
if (next !== -1) {
|
||||||
|
setFormStore("index", next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await selectMethod(store.methodIndex, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const item = current()
|
||||||
|
if (!item || item.prompt.type !== "text") return
|
||||||
|
if (!valid()) return
|
||||||
|
await next(item.index, formStore.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = () => current()
|
||||||
|
const text = createMemo(() => {
|
||||||
|
const prompt = item()?.prompt
|
||||||
|
if (!prompt || prompt.type !== "text") return
|
||||||
|
return prompt
|
||||||
|
})
|
||||||
|
const select = createMemo(() => {
|
||||||
|
const prompt = item()?.prompt
|
||||||
|
if (!prompt || prompt.type !== "select") return
|
||||||
|
return prompt
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
|
<Switch>
|
||||||
|
<Match when={item()?.prompt.type === "text"}>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
label={text()?.message ?? ""}
|
||||||
|
placeholder={text()?.placeholder}
|
||||||
|
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
const prompt = text()
|
||||||
|
if (!prompt) return
|
||||||
|
setFormStore("value", prompt.key, value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
|
||||||
|
{language.t("common.continue")}
|
||||||
|
</Button>
|
||||||
|
</Match>
|
||||||
|
<Match when={item()?.prompt.type === "select"}>
|
||||||
|
<div class="w-full flex flex-col gap-1.5">
|
||||||
|
<div class="text-14-regular text-text-base">{select()?.message}</div>
|
||||||
|
<div>
|
||||||
|
<List
|
||||||
|
items={select()?.options ?? []}
|
||||||
|
key={(x) => x.value}
|
||||||
|
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (!value) return
|
||||||
|
const prompt = select()
|
||||||
|
if (!prompt) return
|
||||||
|
const nextValue = {
|
||||||
|
...formStore.value,
|
||||||
|
[prompt.key]: value.value,
|
||||||
|
}
|
||||||
|
setFormStore("value", prompt.key, value.value)
|
||||||
|
void next(item()!.index, nextValue)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(option) => (
|
||||||
|
<div class="w-full flex items-center gap-x-2">
|
||||||
|
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||||
|
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||||
|
</div>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<span class="text-14-regular text-text-weak">{option.hint}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
function handleKey(e: KeyboardEvent) {
|
function handleKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
||||||
@@ -301,7 +426,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error={formStore.error}
|
error={formStore.error}
|
||||||
/>
|
/>
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
{language.t("common.submit")}
|
{language.t("common.continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,12 +439,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
|
||||||
platform.openLink(store.authorization.url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -368,7 +487,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error={formStore.error}
|
error={formStore.error}
|
||||||
/>
|
/>
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
{language.t("common.submit")}
|
{language.t("common.continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,10 +505,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (store.authorization?.url) {
|
|
||||||
platform.openLink(store.authorization.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
const result = await globalSDK.client.provider.oauth
|
||||||
.callback({
|
.callback({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
@@ -470,6 +585,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={store.state === "prompt"}>
|
||||||
|
<OAuthPromptsView />
|
||||||
|
</Match>
|
||||||
<Match when={store.state === "error"}>
|
<Match when={store.state === "error"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
|
|||||||
@@ -291,8 +291,8 @@ export function DialogSelectServer() {
|
|||||||
navigate("/")
|
navigate("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
server.setActive(ServerConnection.key(conn))
|
|
||||||
navigate("/")
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddChange = (value: string) => {
|
const handleAddChange = (value: string) => {
|
||||||
|
|||||||
@@ -1241,6 +1241,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
// Note: Shift+Enter is handled earlier, before IME check
|
// Note: Shift+Enter is handled earlier, before IME check
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (event.repeat) return
|
||||||
|
if (
|
||||||
|
working() &&
|
||||||
|
prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
|
.trim().length === 0 &&
|
||||||
|
imageAttachments().length === 0 &&
|
||||||
|
commentCount() === 0
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
handleSubmit(event)
|
handleSubmit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,8 +277,8 @@ export function StatusPopover() {
|
|||||||
aria-disabled={isBlocked()}
|
aria-disabled={isBlocked()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isBlocked()) return
|
if (isBlocked()) return
|
||||||
server.setActive(key)
|
|
||||||
navigate("/")
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(key))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ServerHealthIndicator health={health[key]} />
|
<ServerHealthIndicator health={health[key]} />
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const directory = sdk.directory
|
||||||
|
const client = sdk.client
|
||||||
|
const url = sdk.url
|
||||||
|
const auth = server.current?.http
|
||||||
|
const username = auth?.username ?? "opencode"
|
||||||
|
const password = auth?.password ?? ""
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
@@ -215,7 +221,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pushSize = (cols: number, rows: number) => {
|
const pushSize = (cols: number, rows: number) => {
|
||||||
return sdk.client.pty
|
return client.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: id,
|
ptyID: id,
|
||||||
size: { cols, rows },
|
size: { cols, rows },
|
||||||
@@ -474,7 +480,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gone = () =>
|
const gone = () =>
|
||||||
sdk.client.pty
|
client.pty
|
||||||
.get({ ptyID: id })
|
.get({ ptyID: id })
|
||||||
.then(() => false)
|
.then(() => false)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -506,14 +512,14 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
if (disposed) return
|
if (disposed) return
|
||||||
drop?.()
|
drop?.()
|
||||||
|
|
||||||
const url = new URL(sdk.url + `/pty/${id}/connect`)
|
const next = new URL(url + `/pty/${id}/connect`)
|
||||||
url.searchParams.set("directory", sdk.directory)
|
next.searchParams.set("directory", directory)
|
||||||
url.searchParams.set("cursor", String(seek))
|
next.searchParams.set("cursor", String(seek))
|
||||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||||
url.username = server.current?.http.username ?? "opencode"
|
next.username = username
|
||||||
url.password = server.current?.http.password ?? ""
|
next.password = password
|
||||||
|
|
||||||
const socket = new WebSocket(url)
|
const socket = new WebSocket(next)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
ws = socket
|
ws = socket
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,60 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|
||||||
|
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||||
|
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||||
|
const previous = index >= 0 ? store.all[index] : undefined
|
||||||
|
if (index >= 0) {
|
||||||
|
setStore("all", index, (item) => ({ ...item, ...pty }))
|
||||||
|
}
|
||||||
|
client.pty
|
||||||
|
.update({
|
||||||
|
ptyID: pty.id,
|
||||||
|
title: pty.title,
|
||||||
|
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (previous) {
|
||||||
|
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
|
||||||
|
if (currentIndex >= 0) setStore("all", currentIndex, previous)
|
||||||
|
}
|
||||||
|
console.error("Failed to update terminal", error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
|
||||||
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
|
const pty = store.all[index]
|
||||||
|
if (!pty) return
|
||||||
|
const next = await client.pty
|
||||||
|
.create({
|
||||||
|
title: pty.title,
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("Failed to clone terminal", error)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!next?.data) return
|
||||||
|
|
||||||
|
const active = store.active === pty.id
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore("all", index, {
|
||||||
|
id: next.data.id,
|
||||||
|
title: next.data.title ?? pty.title,
|
||||||
|
titleNumber: pty.titleNumber,
|
||||||
|
buffer: undefined,
|
||||||
|
cursor: undefined,
|
||||||
|
scrollY: undefined,
|
||||||
|
rows: undefined,
|
||||||
|
cols: undefined,
|
||||||
|
})
|
||||||
|
if (active) {
|
||||||
|
setStore("active", next.data.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
all: createMemo(() => store.all),
|
all: createMemo(() => store.all),
|
||||||
@@ -216,24 +270,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
update(sdk.client, pty)
|
||||||
const previous = index >= 0 ? store.all[index] : undefined
|
|
||||||
if (index >= 0) {
|
|
||||||
setStore("all", index, (item) => ({ ...item, ...pty }))
|
|
||||||
}
|
|
||||||
sdk.client.pty
|
|
||||||
.update({
|
|
||||||
ptyID: pty.id,
|
|
||||||
title: pty.title,
|
|
||||||
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (previous) {
|
|
||||||
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
|
|
||||||
if (currentIndex >= 0) setStore("all", currentIndex, previous)
|
|
||||||
}
|
|
||||||
console.error("Failed to update terminal", error)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
trim(id: string) {
|
trim(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
@@ -248,37 +285,23 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
await clone(sdk.client, id)
|
||||||
const pty = store.all[index]
|
},
|
||||||
if (!pty) return
|
bind() {
|
||||||
const clone = await sdk.client.pty
|
const client = sdk.client
|
||||||
.create({
|
return {
|
||||||
title: pty.title,
|
trim(id: string) {
|
||||||
})
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
.catch((error: unknown) => {
|
if (index === -1) return
|
||||||
console.error("Failed to clone terminal", error)
|
setStore("all", index, (pty) => trimTerminal(pty))
|
||||||
return undefined
|
},
|
||||||
})
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
if (!clone?.data) return
|
update(client, pty)
|
||||||
|
},
|
||||||
const active = store.active === pty.id
|
async clone(id: string) {
|
||||||
|
await clone(client, id)
|
||||||
batch(() => {
|
},
|
||||||
setStore("all", index, {
|
}
|
||||||
id: clone.data.id,
|
|
||||||
title: clone.data.title ?? pty.title,
|
|
||||||
titleNumber: pty.titleNumber,
|
|
||||||
// New PTY process, so start clean.
|
|
||||||
buffer: undefined,
|
|
||||||
cursor: undefined,
|
|
||||||
scrollY: undefined,
|
|
||||||
rows: undefined,
|
|
||||||
cols: undefined,
|
|
||||||
})
|
|
||||||
if (active) {
|
|
||||||
setStore("active", clone.data.id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
open(id: string) {
|
open(id: string) {
|
||||||
setStore("active", id)
|
setStore("active", id)
|
||||||
@@ -403,6 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
trim: (id: string) => workspace().trim(id),
|
trim: (id: string) => workspace().trim(id),
|
||||||
trimAll: () => workspace().trimAll(),
|
trimAll: () => workspace().trimAll(),
|
||||||
clone: (id: string) => workspace().clone(id),
|
clone: (id: string) => workspace().clone(id),
|
||||||
|
bind: () => workspace(),
|
||||||
open: (id: string) => workspace().open(id),
|
open: (id: string) => workspace().open(id),
|
||||||
close: (id: string) => workspace().close(id),
|
close: (id: string) => workspace().close(id),
|
||||||
move: (id: string, to: number) => workspace().move(id, to),
|
move: (id: string, to: number) => workspace().move(id, to),
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export const dict = {
|
|||||||
"common.cancel": "إلغاء",
|
"common.cancel": "إلغاء",
|
||||||
"common.connect": "اتصال",
|
"common.connect": "اتصال",
|
||||||
"common.disconnect": "قطع الاتصال",
|
"common.disconnect": "قطع الاتصال",
|
||||||
|
"common.continue": "إرسال",
|
||||||
"common.submit": "إرسال",
|
"common.submit": "إرسال",
|
||||||
"common.save": "حفظ",
|
"common.save": "حفظ",
|
||||||
"common.saving": "جارٍ الحفظ...",
|
"common.saving": "جارٍ الحفظ...",
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export const dict = {
|
|||||||
"common.cancel": "Cancelar",
|
"common.cancel": "Cancelar",
|
||||||
"common.connect": "Conectar",
|
"common.connect": "Conectar",
|
||||||
"common.disconnect": "Desconectar",
|
"common.disconnect": "Desconectar",
|
||||||
|
"common.continue": "Enviar",
|
||||||
"common.submit": "Enviar",
|
"common.submit": "Enviar",
|
||||||
"common.save": "Salvar",
|
"common.save": "Salvar",
|
||||||
"common.saving": "Salvando...",
|
"common.saving": "Salvando...",
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export const dict = {
|
|||||||
"common.cancel": "Otkaži",
|
"common.cancel": "Otkaži",
|
||||||
"common.connect": "Poveži",
|
"common.connect": "Poveži",
|
||||||
"common.disconnect": "Prekini vezu",
|
"common.disconnect": "Prekini vezu",
|
||||||
|
"common.continue": "Pošalji",
|
||||||
"common.submit": "Pošalji",
|
"common.submit": "Pošalji",
|
||||||
"common.save": "Sačuvaj",
|
"common.save": "Sačuvaj",
|
||||||
"common.saving": "Čuvanje...",
|
"common.saving": "Čuvanje...",
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export const dict = {
|
|||||||
"common.cancel": "Annuller",
|
"common.cancel": "Annuller",
|
||||||
"common.connect": "Forbind",
|
"common.connect": "Forbind",
|
||||||
"common.disconnect": "Frakobl",
|
"common.disconnect": "Frakobl",
|
||||||
|
"common.continue": "Indsend",
|
||||||
"common.submit": "Indsend",
|
"common.submit": "Indsend",
|
||||||
"common.save": "Gem",
|
"common.save": "Gem",
|
||||||
"common.saving": "Gemmer...",
|
"common.saving": "Gemmer...",
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export const dict = {
|
|||||||
"common.cancel": "Abbrechen",
|
"common.cancel": "Abbrechen",
|
||||||
"common.connect": "Verbinden",
|
"common.connect": "Verbinden",
|
||||||
"common.disconnect": "Trennen",
|
"common.disconnect": "Trennen",
|
||||||
|
"common.continue": "Absenden",
|
||||||
"common.submit": "Absenden",
|
"common.submit": "Absenden",
|
||||||
"common.save": "Speichern",
|
"common.save": "Speichern",
|
||||||
"common.saving": "Speichert...",
|
"common.saving": "Speichert...",
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export const dict = {
|
|||||||
"common.open": "Open",
|
"common.open": "Open",
|
||||||
"common.connect": "Connect",
|
"common.connect": "Connect",
|
||||||
"common.disconnect": "Disconnect",
|
"common.disconnect": "Disconnect",
|
||||||
|
"common.continue": "Continue",
|
||||||
"common.submit": "Submit",
|
"common.submit": "Submit",
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.saving": "Saving...",
|
"common.saving": "Saving...",
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export const dict = {
|
|||||||
"common.cancel": "Cancelar",
|
"common.cancel": "Cancelar",
|
||||||
"common.connect": "Conectar",
|
"common.connect": "Conectar",
|
||||||
"common.disconnect": "Desconectar",
|
"common.disconnect": "Desconectar",
|
||||||
|
"common.continue": "Enviar",
|
||||||
"common.submit": "Enviar",
|
"common.submit": "Enviar",
|
||||||
"common.save": "Guardar",
|
"common.save": "Guardar",
|
||||||
"common.saving": "Guardando...",
|
"common.saving": "Guardando...",
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export const dict = {
|
|||||||
"common.cancel": "Annuler",
|
"common.cancel": "Annuler",
|
||||||
"common.connect": "Connecter",
|
"common.connect": "Connecter",
|
||||||
"common.disconnect": "Déconnecter",
|
"common.disconnect": "Déconnecter",
|
||||||
|
"common.continue": "Soumettre",
|
||||||
"common.submit": "Soumettre",
|
"common.submit": "Soumettre",
|
||||||
"common.save": "Enregistrer",
|
"common.save": "Enregistrer",
|
||||||
"common.saving": "Enregistrement...",
|
"common.saving": "Enregistrement...",
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export const dict = {
|
|||||||
"common.cancel": "キャンセル",
|
"common.cancel": "キャンセル",
|
||||||
"common.connect": "接続",
|
"common.connect": "接続",
|
||||||
"common.disconnect": "切断",
|
"common.disconnect": "切断",
|
||||||
|
"common.continue": "送信",
|
||||||
"common.submit": "送信",
|
"common.submit": "送信",
|
||||||
"common.save": "保存",
|
"common.save": "保存",
|
||||||
"common.saving": "保存中...",
|
"common.saving": "保存中...",
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export const dict = {
|
|||||||
"common.cancel": "취소",
|
"common.cancel": "취소",
|
||||||
"common.connect": "연결",
|
"common.connect": "연결",
|
||||||
"common.disconnect": "연결 해제",
|
"common.disconnect": "연결 해제",
|
||||||
|
"common.continue": "제출",
|
||||||
"common.submit": "제출",
|
"common.submit": "제출",
|
||||||
"common.save": "저장",
|
"common.save": "저장",
|
||||||
"common.saving": "저장 중...",
|
"common.saving": "저장 중...",
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const dict = {
|
|||||||
"common.cancel": "Avbryt",
|
"common.cancel": "Avbryt",
|
||||||
"common.connect": "Koble til",
|
"common.connect": "Koble til",
|
||||||
"common.disconnect": "Koble fra",
|
"common.disconnect": "Koble fra",
|
||||||
|
"common.continue": "Send inn",
|
||||||
"common.submit": "Send inn",
|
"common.submit": "Send inn",
|
||||||
"common.save": "Lagre",
|
"common.save": "Lagre",
|
||||||
"common.saving": "Lagrer...",
|
"common.saving": "Lagrer...",
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export const dict = {
|
|||||||
"common.cancel": "Anuluj",
|
"common.cancel": "Anuluj",
|
||||||
"common.connect": "Połącz",
|
"common.connect": "Połącz",
|
||||||
"common.disconnect": "Rozłącz",
|
"common.disconnect": "Rozłącz",
|
||||||
|
"common.continue": "Prześlij",
|
||||||
"common.submit": "Prześlij",
|
"common.submit": "Prześlij",
|
||||||
"common.save": "Zapisz",
|
"common.save": "Zapisz",
|
||||||
"common.saving": "Zapisywanie...",
|
"common.saving": "Zapisywanie...",
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export const dict = {
|
|||||||
"common.cancel": "Отмена",
|
"common.cancel": "Отмена",
|
||||||
"common.connect": "Подключить",
|
"common.connect": "Подключить",
|
||||||
"common.disconnect": "Отключить",
|
"common.disconnect": "Отключить",
|
||||||
|
"common.continue": "Отправить",
|
||||||
"common.submit": "Отправить",
|
"common.submit": "Отправить",
|
||||||
"common.save": "Сохранить",
|
"common.save": "Сохранить",
|
||||||
"common.saving": "Сохранение...",
|
"common.saving": "Сохранение...",
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export const dict = {
|
|||||||
"common.cancel": "ยกเลิก",
|
"common.cancel": "ยกเลิก",
|
||||||
"common.connect": "เชื่อมต่อ",
|
"common.connect": "เชื่อมต่อ",
|
||||||
"common.disconnect": "ยกเลิกการเชื่อมต่อ",
|
"common.disconnect": "ยกเลิกการเชื่อมต่อ",
|
||||||
|
"common.continue": "ส่ง",
|
||||||
"common.submit": "ส่ง",
|
"common.submit": "ส่ง",
|
||||||
"common.save": "บันทึก",
|
"common.save": "บันทึก",
|
||||||
"common.saving": "กำลังบันทึก...",
|
"common.saving": "กำลังบันทึก...",
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ export const dict = {
|
|||||||
"common.cancel": "İptal",
|
"common.cancel": "İptal",
|
||||||
"common.connect": "Bağlan",
|
"common.connect": "Bağlan",
|
||||||
"common.disconnect": "Bağlantı Kes",
|
"common.disconnect": "Bağlantı Kes",
|
||||||
|
"common.continue": "Gönder",
|
||||||
"common.submit": "Gönder",
|
"common.submit": "Gönder",
|
||||||
"common.save": "Kaydet",
|
"common.save": "Kaydet",
|
||||||
"common.saving": "Kaydediliyor...",
|
"common.saving": "Kaydediliyor...",
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export const dict = {
|
|||||||
"common.cancel": "取消",
|
"common.cancel": "取消",
|
||||||
"common.connect": "连接",
|
"common.connect": "连接",
|
||||||
"common.disconnect": "断开连接",
|
"common.disconnect": "断开连接",
|
||||||
|
"common.continue": "提交",
|
||||||
"common.submit": "提交",
|
"common.submit": "提交",
|
||||||
"common.save": "保存",
|
"common.save": "保存",
|
||||||
"common.saving": "保存中...",
|
"common.saving": "保存中...",
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export const dict = {
|
|||||||
"common.cancel": "取消",
|
"common.cancel": "取消",
|
||||||
"common.connect": "連線",
|
"common.connect": "連線",
|
||||||
"common.disconnect": "中斷連線",
|
"common.disconnect": "中斷連線",
|
||||||
|
"common.continue": "提交",
|
||||||
"common.submit": "提交",
|
"common.submit": "提交",
|
||||||
"common.save": "儲存",
|
"common.save": "儲存",
|
||||||
"common.saving": "儲存中...",
|
"common.saving": "儲存中...",
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
import { createStore } from "solid-js/store"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { createMemo, createResource, type ParentProps, Show } from "solid-js"
|
||||||
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { LocalProvider } from "@/context/local"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { SyncProvider, useSync } from "@/context/sync"
|
import { SyncProvider, useSync } from "@/context/sync"
|
||||||
import { LocalProvider } from "@/context/local"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
|
||||||
|
|
||||||
import { DataProvider } from "@opencode-ai/ui/context"
|
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -30,57 +29,53 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
|||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const directory = createMemo(() => decode64(params.dir) ?? "")
|
const navigate = useNavigate()
|
||||||
const [state, setState] = createStore({ invalid: "", resolved: "" })
|
let invalid = ""
|
||||||
|
|
||||||
createEffect(() => {
|
const [resolved] = createResource(
|
||||||
if (!params.dir) return
|
() => {
|
||||||
const raw = directory()
|
if (params.dir) return [location.pathname, params.dir] as const
|
||||||
if (!raw) {
|
},
|
||||||
if (state.invalid === params.dir) return
|
async ([pathname, b64Dir]) => {
|
||||||
setState("invalid", params.dir)
|
const directory = decode64(b64Dir)
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: language.t("directory.error.invalidUrl"),
|
|
||||||
})
|
|
||||||
navigate("/", { replace: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = params.dir
|
if (!directory) {
|
||||||
globalSDK
|
if (invalid === params.dir) return
|
||||||
.createClient({
|
invalid = b64Dir
|
||||||
directory: raw,
|
showToast({
|
||||||
throwOnError: true,
|
variant: "error",
|
||||||
})
|
title: language.t("common.requestFailed"),
|
||||||
.path.get()
|
description: language.t("directory.error.invalidUrl"),
|
||||||
.then((x) => {
|
|
||||||
if (params.dir !== current) return
|
|
||||||
const next = x.data?.directory ?? raw
|
|
||||||
batch(() => {
|
|
||||||
setState("invalid", "")
|
|
||||||
setState("resolved", next)
|
|
||||||
})
|
})
|
||||||
if (next === raw) return
|
navigate("/", { replace: true })
|
||||||
const path = location.pathname.slice(current.length + 1)
|
return
|
||||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
}
|
||||||
})
|
|
||||||
.catch(() => {
|
return await globalSDK
|
||||||
if (params.dir !== current) return
|
.createClient({
|
||||||
batch(() => {
|
directory,
|
||||||
setState("invalid", "")
|
throwOnError: true,
|
||||||
setState("resolved", raw)
|
|
||||||
})
|
})
|
||||||
})
|
.path.get()
|
||||||
})
|
.then((x) => {
|
||||||
|
const next = x.data?.directory ?? directory
|
||||||
|
invalid = ""
|
||||||
|
if (next === directory) return next
|
||||||
|
const path = pathname.slice(b64Dir.length + 1)
|
||||||
|
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
invalid = ""
|
||||||
|
return directory
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={state.resolved} keyed>
|
<Show when={resolved()} keyed>
|
||||||
{(resolved) => (
|
{(resolved) => (
|
||||||
<SDKProvider directory={() => resolved}>
|
<SDKProvider directory={() => resolved}>
|
||||||
<SyncProvider>
|
<SyncProvider>
|
||||||
|
|||||||
@@ -543,13 +543,14 @@ export default function Layout(props: ParentProps) {
|
|||||||
const currentProject = createMemo(() => {
|
const currentProject = createMemo(() => {
|
||||||
const directory = currentDir()
|
const directory = currentDir()
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
|
const key = workspaceKey(directory)
|
||||||
|
|
||||||
const projects = layout.projects.list()
|
const projects = layout.projects.list()
|
||||||
|
|
||||||
const sandbox = projects.find((p) => p.sandboxes?.includes(directory))
|
const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
|
||||||
if (sandbox) return sandbox
|
if (sandbox) return sandbox
|
||||||
|
|
||||||
const direct = projects.find((p) => p.worktree === directory)
|
const direct = projects.find((p) => workspaceKey(p.worktree) === key)
|
||||||
if (direct) return direct
|
if (direct) return direct
|
||||||
|
|
||||||
const [child] = globalSync.child(directory, { bootstrap: false })
|
const [child] = globalSync.child(directory, { bootstrap: false })
|
||||||
@@ -630,7 +631,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
const projects = layout.projects.list()
|
const projects = layout.projects.list()
|
||||||
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
|
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
|
||||||
if (!expanded) continue
|
if (!expanded) continue
|
||||||
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
const key = workspaceKey(directory)
|
||||||
|
const project = projects.find(
|
||||||
|
(item) =>
|
||||||
|
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
|
||||||
|
)
|
||||||
if (!project) continue
|
if (!project) continue
|
||||||
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
|
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
|
||||||
setStore("workspaceExpanded", directory, false)
|
setStore("workspaceExpanded", directory, false)
|
||||||
@@ -1155,13 +1160,17 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function projectRoot(directory: string) {
|
function projectRoot(directory: string) {
|
||||||
|
const key = workspaceKey(directory)
|
||||||
const project = layout.projects
|
const project = layout.projects
|
||||||
.list()
|
.list()
|
||||||
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
|
.find(
|
||||||
|
(item) =>
|
||||||
|
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
|
||||||
|
)
|
||||||
if (project) return project.worktree
|
if (project) return project.worktree
|
||||||
|
|
||||||
const known = Object.entries(store.workspaceOrder).find(
|
const known = Object.entries(store.workspaceOrder).find(
|
||||||
([root, dirs]) => root === directory || dirs.includes(directory),
|
([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
|
||||||
)
|
)
|
||||||
if (known) return known[0]
|
if (known) return known[0]
|
||||||
|
|
||||||
@@ -1177,13 +1186,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
return currentProject()?.worktree ?? projectRoot(directory)
|
return currentProject()?.worktree ?? projectRoot(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
function touchProjectRoute() {
|
|
||||||
const root = currentProject()?.worktree
|
|
||||||
if (!root) return
|
|
||||||
if (server.projects.last() !== root) server.projects.touch(root)
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||||
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
|
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
|
||||||
return root
|
return root
|
||||||
@@ -1347,8 +1349,9 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
function closeProject(directory: string) {
|
function closeProject(directory: string) {
|
||||||
const list = layout.projects.list()
|
const list = layout.projects.list()
|
||||||
const index = list.findIndex((x) => x.worktree === directory)
|
const key = workspaceKey(directory)
|
||||||
const active = currentProject()?.worktree === directory
|
const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
|
||||||
|
const active = workspaceKey(currentProject()?.worktree ?? "") === key
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
const next = list[index + 1]
|
const next = list[index + 1]
|
||||||
|
|
||||||
@@ -1683,38 +1686,55 @@ export default function Layout(props: ParentProps) {
|
|||||||
const activeRoute = {
|
const activeRoute = {
|
||||||
session: "",
|
session: "",
|
||||||
sessionProject: "",
|
sessionProject: "",
|
||||||
|
directory: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
|
() => {
|
||||||
([ready, dir, id]) => {
|
const dir = params.dir
|
||||||
if (!ready || !dir) {
|
const directory = dir ? decode64(dir) : undefined
|
||||||
|
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
|
||||||
|
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
|
||||||
|
},
|
||||||
|
([ready, dir, id, root, directory, resolved]) => {
|
||||||
|
if (!ready || !dir || !directory) {
|
||||||
activeRoute.session = ""
|
activeRoute.session = ""
|
||||||
activeRoute.sessionProject = ""
|
activeRoute.sessionProject = ""
|
||||||
|
activeRoute.directory = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const directory = decode64(dir)
|
|
||||||
if (!directory) return
|
|
||||||
|
|
||||||
const root = touchProjectRoute() ?? activeProjectRoot(directory)
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
activeRoute.session = ""
|
activeRoute.session = ""
|
||||||
activeRoute.sessionProject = ""
|
activeRoute.sessionProject = ""
|
||||||
|
activeRoute.directory = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const next = resolved || directory
|
||||||
const session = `${dir}/${id}`
|
const session = `${dir}/${id}`
|
||||||
if (session !== activeRoute.session) {
|
|
||||||
|
if (!root) {
|
||||||
activeRoute.session = session
|
activeRoute.session = session
|
||||||
activeRoute.sessionProject = syncSessionRoute(directory, id, root)
|
activeRoute.directory = next
|
||||||
|
activeRoute.sessionProject = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.projects.last() !== root) server.projects.touch(root)
|
||||||
|
|
||||||
|
const changed = session !== activeRoute.session || next !== activeRoute.directory
|
||||||
|
if (changed) {
|
||||||
|
activeRoute.session = session
|
||||||
|
activeRoute.directory = next
|
||||||
|
activeRoute.sessionProject = syncSessionRoute(next, id, root)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (root === activeRoute.sessionProject) return
|
if (root === activeRoute.sessionProject) return
|
||||||
activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
|
activeRoute.directory = next
|
||||||
|
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1778,8 +1798,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
const local = project.worktree
|
const local = project.worktree
|
||||||
const dirs = [local, ...(project.sandboxes ?? [])]
|
const dirs = [local, ...(project.sandboxes ?? [])]
|
||||||
const active = currentProject()
|
const active = currentProject()
|
||||||
const directory = active?.worktree === project.worktree ? currentDir() : undefined
|
const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
|
||||||
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
const extra =
|
||||||
|
directory &&
|
||||||
|
workspaceKey(directory) !== workspaceKey(local) &&
|
||||||
|
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
|
||||||
|
? directory
|
||||||
|
: undefined
|
||||||
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
||||||
|
|
||||||
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
||||||
|
|||||||
@@ -104,14 +104,14 @@ describe("layout deep links", () => {
|
|||||||
describe("layout workspace helpers", () => {
|
describe("layout workspace helpers", () => {
|
||||||
test("normalizes trailing slash in workspace key", () => {
|
test("normalizes trailing slash in workspace key", () => {
|
||||||
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
|
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
|
||||||
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo")
|
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves posix and drive roots in workspace key", () => {
|
test("preserves posix and drive roots in workspace key", () => {
|
||||||
expect(workspaceKey("/")).toBe("/")
|
expect(workspaceKey("/")).toBe("/")
|
||||||
expect(workspaceKey("///")).toBe("/")
|
expect(workspaceKey("///")).toBe("/")
|
||||||
expect(workspaceKey("C:\\")).toBe("C:\\")
|
expect(workspaceKey("C:\\")).toBe("C:/")
|
||||||
expect(workspaceKey("C:\\\\\\")).toBe("C:\\")
|
expect(workspaceKey("C://")).toBe("C:/")
|
||||||
expect(workspaceKey("C:///")).toBe("C:/")
|
expect(workspaceKey("C:///")).toBe("C:/")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
type SessionStore = {
|
||||||
|
session?: Session[]
|
||||||
|
path: { directory: string }
|
||||||
|
}
|
||||||
|
|
||||||
export const workspaceKey = (directory: string) => {
|
export const workspaceKey = (directory: string) => {
|
||||||
const drive = directory.match(/^([A-Za-z]:)[\\/]+$/)
|
const value = directory.replaceAll("\\", "/")
|
||||||
if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}`
|
const drive = value.match(/^([A-Za-z]:)\/+$/)
|
||||||
if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/"
|
if (drive) return `${drive[1]}/`
|
||||||
return directory.replace(/[\\/]+$/, "")
|
if (/^\/+$/i.test(value)) return "/"
|
||||||
|
return value.replace(/\/+$/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortSessions(now: number) {
|
function sortSessions(now: number) {
|
||||||
@@ -25,13 +31,13 @@ function sortSessions(now: number) {
|
|||||||
const isRootVisibleSession = (session: Session, directory: string) =>
|
const isRootVisibleSession = (session: Session, directory: string) =>
|
||||||
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
|
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
|
||||||
|
|
||||||
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
|
const roots = (store: SessionStore) =>
|
||||||
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
|
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
|
||||||
|
|
||||||
export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) =>
|
export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
|
||||||
stores
|
|
||||||
.flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory)))
|
export const latestRootSession = (stores: SessionStore[], now: number) =>
|
||||||
.sort(sortSessions(now))[0]
|
stores.flatMap(roots).sort(sortSessions(now))[0]
|
||||||
|
|
||||||
export function hasProjectPermissions<T>(
|
export function hasProjectPermissions<T>(
|
||||||
request: Record<string, T[] | undefined>,
|
request: Record<string, T[] | undefined>,
|
||||||
@@ -40,9 +46,9 @@ export function hasProjectPermissions<T>(
|
|||||||
return Object.values(request).some((list) => list?.some(include))
|
return Object.values(request).some((list) => list?.some(include))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const childMapByParent = (sessions: Session[]) => {
|
export const childMapByParent = (sessions: Session[] | undefined) => {
|
||||||
const map = new Map<string, string[]>()
|
const map = new Map<string, string[]>()
|
||||||
for (const session of sessions) {
|
for (const session of sessions ?? []) {
|
||||||
if (!session.parentID) continue
|
if (!session.parentID) continue
|
||||||
const existing = map.get(session.parentID)
|
const existing = map.get(session.parentID)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -332,12 +332,13 @@ export const SortableWorkspace = (props: {
|
|||||||
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
||||||
const boot = createMemo(() => open() || active())
|
const boot = createMemo(() => open() || active())
|
||||||
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
|
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
|
||||||
const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
|
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
|
||||||
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
||||||
const wasBusy = createMemo((prev) => prev || busy(), false)
|
const wasBusy = createMemo((prev) => prev || busy(), false)
|
||||||
const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
|
const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
|
||||||
const touch = createMediaQuery("(hover: none)")
|
const touch = createMediaQuery("(hover: none)")
|
||||||
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id)))
|
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
await globalSync.project.loadSessions(props.directory)
|
await globalSync.project.loadSessions(props.directory)
|
||||||
@@ -472,8 +473,9 @@ export const LocalWorkspace = (props: {
|
|||||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||||
const children = createMemo(() => childMapByParent(workspace().store.session))
|
const children = createMemo(() => childMapByParent(workspace().store.session))
|
||||||
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
||||||
const loading = createMemo(() => !booted() && sessions().length === 0)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
|
const loading = createMemo(() => !booted() && count() === 0)
|
||||||
|
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
||||||
await globalSync.project.loadSessions(props.project.worktree)
|
await globalSync.project.loadSessions(props.project.worktree)
|
||||||
|
|||||||
@@ -280,21 +280,24 @@ export function TerminalPanel() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
<div class="flex-1 min-h-0 relative">
|
<div class="flex-1 min-h-0 relative">
|
||||||
<Show when={terminal.active()} keyed>
|
<Show when={terminal.active()} keyed>
|
||||||
{(id) => (
|
{(id) => {
|
||||||
<Show when={all().find((pty) => pty.id === id)}>
|
const ops = terminal.bind()
|
||||||
{(pty) => (
|
return (
|
||||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
<Show when={all().find((pty) => pty.id === id)}>
|
||||||
<Terminal
|
{(pty) => (
|
||||||
pty={pty()}
|
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||||
autoFocus={opened()}
|
<Terminal
|
||||||
onConnect={() => terminal.trim(id)}
|
pty={pty()}
|
||||||
onCleanup={terminal.update}
|
autoFocus={opened()}
|
||||||
onConnectError={() => terminal.clone(id)}
|
onConnect={() => ops.trim(id)}
|
||||||
/>
|
onCleanup={ops.update}
|
||||||
</div>
|
onConnectError={() => ops.clone(id)}
|
||||||
)}
|
/>
|
||||||
</Show>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,12 +152,12 @@ const createPlatform = (): Platform => {
|
|||||||
storage,
|
storage,
|
||||||
|
|
||||||
checkUpdate: async () => {
|
checkUpdate: async () => {
|
||||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
if (!UPDATER_ENABLED()) return { updateAvailable: false }
|
||||||
return window.api.checkUpdate()
|
return window.api.checkUpdate()
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async () => {
|
update: async () => {
|
||||||
if (!UPDATER_ENABLED) return
|
if (!UPDATER_ENABLED()) return
|
||||||
await window.api.installUpdate()
|
await window.api.installUpdate()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { initI18n, t } from "./i18n"
|
import { initI18n, t } from "./i18n"
|
||||||
|
|
||||||
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
|
export const UPDATER_ENABLED = () => window.__OPENCODE__?.updaterEnabled ?? false
|
||||||
|
|
||||||
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
|
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
|
||||||
await initI18n()
|
await initI18n()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { ZodType } from "zod"
|
import type { ZodObject, ZodRawShape } from "zod"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
export namespace BusEvent {
|
export namespace BusEvent {
|
||||||
@@ -9,7 +9,7 @@ export namespace BusEvent {
|
|||||||
|
|
||||||
const registry = new Map<string, Definition>()
|
const registry = new Map<string, Definition>()
|
||||||
|
|
||||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
|
||||||
const result = {
|
const result = {
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
|
|||||||
event: [
|
event: [
|
||||||
{
|
{
|
||||||
directory?: string
|
directory?: string
|
||||||
payload: any
|
payload: { type: string; properties: Record<string, unknown> }
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Effect, Layer, PubSub, ServiceMap, Stream } from "effect"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { BusEvent } from "./bus-event"
|
import { BusEvent } from "./bus-event"
|
||||||
import { GlobalBus } from "./global"
|
import { GlobalBus } from "./global"
|
||||||
|
import { runCallbackInstance, runPromiseInstance } from "../effect/runtime"
|
||||||
|
|
||||||
export namespace Bus {
|
export namespace Bus {
|
||||||
const log = Log.create({ service: "bus" })
|
const log = Log.create({ service: "bus" })
|
||||||
type Subscription = (event: any) => void
|
|
||||||
|
|
||||||
export const InstanceDisposed = BusEvent.define(
|
export const InstanceDisposed = BusEvent.define(
|
||||||
"server.instance.disposed",
|
"server.instance.disposed",
|
||||||
@@ -15,91 +16,130 @@ export namespace Bus {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const state = Instance.state(
|
// ---------------------------------------------------------------------------
|
||||||
() => {
|
// Service definition
|
||||||
const subscriptions = new Map<any, Subscription[]>()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
return {
|
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||||
subscriptions,
|
type: D["type"]
|
||||||
|
properties: z.infer<D["properties"]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly publish: <D extends BusEvent.Definition>(
|
||||||
|
def: D,
|
||||||
|
properties: z.output<D["properties"]>,
|
||||||
|
) => Effect.Effect<void>
|
||||||
|
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||||
|
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pubsubs = new Map<string, PubSub.PubSub<Payload>>()
|
||||||
|
const wildcardPubSub = yield* PubSub.unbounded<Payload>()
|
||||||
|
|
||||||
|
const getOrCreate = Effect.fnUntraced(function* (type: string) {
|
||||||
|
let ps = pubsubs.get(type)
|
||||||
|
if (!ps) {
|
||||||
|
ps = yield* PubSub.unbounded<Payload>()
|
||||||
|
pubsubs.set(type, ps)
|
||||||
|
}
|
||||||
|
return ps
|
||||||
|
})
|
||||||
|
|
||||||
|
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const payload: Payload = { type: def.type, properties }
|
||||||
|
log.info("publishing", { type: def.type })
|
||||||
|
|
||||||
|
const ps = pubsubs.get(def.type)
|
||||||
|
if (ps) yield* PubSub.publish(ps, payload)
|
||||||
|
yield* PubSub.publish(wildcardPubSub, payload)
|
||||||
|
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: Instance.directory,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
|
||||||
async (entry) => {
|
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
||||||
const wildcard = entry.subscriptions.get("*")
|
log.info("subscribing", { type: def.type })
|
||||||
if (!wildcard) return
|
return Stream.unwrap(
|
||||||
const event = {
|
Effect.gen(function* () {
|
||||||
type: InstanceDisposed.type,
|
const ps = yield* getOrCreate(def.type)
|
||||||
properties: {
|
return Stream.fromPubSub(ps) as Stream.Stream<Payload<D>>
|
||||||
directory: Instance.directory,
|
}),
|
||||||
},
|
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||||
}
|
}
|
||||||
for (const sub of [...wildcard]) {
|
|
||||||
sub(event)
|
function subscribeAll(): Stream.Stream<Payload> {
|
||||||
|
log.info("subscribing", { type: "*" })
|
||||||
|
return Stream.fromPubSub(wildcardPubSub).pipe(
|
||||||
|
Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
// Shut down all PubSubs when the layer is torn down.
|
||||||
|
// This causes Stream.fromPubSub consumers to end, triggering
|
||||||
|
// their ensuring/finalizers.
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
log.info("shutting down PubSubs")
|
||||||
|
yield* PubSub.shutdown(wildcardPubSub)
|
||||||
|
for (const ps of pubsubs.values()) {
|
||||||
|
yield* PubSub.shutdown(ps)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({ publish, subscribe, subscribeAll })
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function publish<Definition extends BusEvent.Definition>(
|
// ---------------------------------------------------------------------------
|
||||||
def: Definition,
|
// Legacy adapters — plain function API wrapping the Effect service
|
||||||
properties: z.output<Definition["properties"]>,
|
// ---------------------------------------------------------------------------
|
||||||
) {
|
|
||||||
const payload = {
|
function runStream(stream: (svc: Interface) => Stream.Stream<Payload>, callback: (event: any) => void) {
|
||||||
type: def.type,
|
return runCallbackInstance(
|
||||||
properties,
|
Service.use((svc) => stream(svc).pipe(Stream.runForEach((msg) => Effect.sync(() => callback(msg))))),
|
||||||
}
|
)
|
||||||
log.info("publishing", {
|
|
||||||
type: def.type,
|
|
||||||
})
|
|
||||||
const pending = []
|
|
||||||
for (const key of [def.type, "*"]) {
|
|
||||||
const match = state().subscriptions.get(key)
|
|
||||||
for (const sub of match ?? []) {
|
|
||||||
pending.push(sub(payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory: Instance.directory,
|
|
||||||
payload,
|
|
||||||
})
|
|
||||||
return Promise.all(pending)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribe<Definition extends BusEvent.Definition>(
|
export function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||||
def: Definition,
|
return runPromiseInstance(Service.use((svc) => svc.publish(def, properties)))
|
||||||
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
|
|
||||||
) {
|
|
||||||
return raw(def.type, callback)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function once<Definition extends BusEvent.Definition>(
|
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => void) {
|
||||||
def: Definition,
|
return runStream((svc) => svc.subscribe(def), callback)
|
||||||
callback: (event: {
|
|
||||||
type: Definition["type"]
|
|
||||||
properties: z.infer<Definition["properties"]>
|
|
||||||
}) => "done" | undefined,
|
|
||||||
) {
|
|
||||||
const unsub = subscribe(def, (event) => {
|
|
||||||
if (callback(event)) unsub()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeAll(callback: (event: any) => void) {
|
export function subscribeAll(callback: (event: any) => void) {
|
||||||
return raw("*", callback)
|
const directory = Instance.directory
|
||||||
}
|
|
||||||
|
|
||||||
function raw(type: string, callback: (event: any) => void) {
|
// InstanceDisposed is delivered via GlobalBus because the legacy
|
||||||
log.info("subscribing", { type })
|
// adapter's fiber starts asynchronously and may not be running when
|
||||||
const subscriptions = state().subscriptions
|
// disposal happens. In the Effect-native path, forkScoped + scope
|
||||||
let match = subscriptions.get(type) ?? []
|
// closure handles this correctly. This bridge can be removed once
|
||||||
match.push(callback)
|
// upstream PubSub.shutdown properly wakes suspended subscribers:
|
||||||
subscriptions.set(type, match)
|
// https://github.com/Effect-TS/effect-smol/pull/1800
|
||||||
|
const onDispose = (evt: { directory?: string; payload: any }) => {
|
||||||
|
if (evt.payload.type !== InstanceDisposed.type) return
|
||||||
|
if (evt.directory !== directory) return
|
||||||
|
callback(evt.payload)
|
||||||
|
GlobalBus.off("event", onDispose)
|
||||||
|
}
|
||||||
|
GlobalBus.on("event", onDispose)
|
||||||
|
|
||||||
|
const interrupt = runStream((svc) => svc.subscribeAll(), callback)
|
||||||
return () => {
|
return () => {
|
||||||
log.info("unsubscribing", { type })
|
GlobalBus.off("event", onDispose)
|
||||||
const match = subscriptions.get(type)
|
interrupt()
|
||||||
if (!match) return
|
|
||||||
const index = match.indexOf(callback)
|
|
||||||
if (index === -1) return
|
|
||||||
match.splice(index, 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,7 +349,6 @@ export const ProvidersLoginCommand = cmd({
|
|||||||
value: x.id,
|
value: x.id,
|
||||||
hint: {
|
hint: {
|
||||||
opencode: "recommended",
|
opencode: "recommended",
|
||||||
anthropic: "API key",
|
|
||||||
openai: "ChatGPT Plus/Pro or API key",
|
openai: "ChatGPT Plus/Pro or API key",
|
||||||
}[x.id],
|
}[x.id],
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { MessageID, PartID } from "@/session/schema"
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useKeybind } from "@tui/context/keybind"
|
import { useKeybind } from "@tui/context/keybind"
|
||||||
import { usePromptHistory, type PromptInfo } from "./history"
|
import { usePromptHistory, type PromptInfo } from "./history"
|
||||||
|
import { assign } from "./part"
|
||||||
import { usePromptStash } from "./stash"
|
import { usePromptStash } from "./stash"
|
||||||
import { DialogStash } from "../dialog-stash"
|
import { DialogStash } from "../dialog-stash"
|
||||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||||
@@ -643,10 +644,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
type: "text",
|
type: "text",
|
||||||
text: inputText,
|
text: inputText,
|
||||||
},
|
},
|
||||||
...nonTextParts.map((x) => ({
|
...nonTextParts.map(assign),
|
||||||
id: PartID.ascending(),
|
|
||||||
...x,
|
|
||||||
})),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { PartID } from "@/session/schema"
|
||||||
|
import type { PromptInfo } from "./history"
|
||||||
|
|
||||||
|
type Item = PromptInfo["parts"][number]
|
||||||
|
|
||||||
|
export function strip(part: Item & { id: string; messageID: string; sessionID: string }): Item {
|
||||||
|
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assign(part: Item): Item & { id: PartID } {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
id: PartID.ascending(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useSDK } from "@tui/context/sdk"
|
|||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||||
|
import { strip } from "@tui/component/prompt/part"
|
||||||
|
|
||||||
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
|
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
@@ -42,7 +43,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
|||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
if (!part.synthetic) agg.input += part.text
|
if (!part.synthetic) agg.input += part.text
|
||||||
}
|
}
|
||||||
if (part.type === "file") agg.parts.push(part)
|
if (part.type === "file") agg.parts.push(strip(part))
|
||||||
return agg
|
return agg
|
||||||
},
|
},
|
||||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSDK } from "@tui/context/sdk"
|
|||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { Clipboard } from "@tui/util/clipboard"
|
import { Clipboard } from "@tui/util/clipboard"
|
||||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||||
|
import { strip } from "@tui/component/prompt/part"
|
||||||
|
|
||||||
export function DialogMessage(props: {
|
export function DialogMessage(props: {
|
||||||
messageID: string
|
messageID: string
|
||||||
@@ -40,7 +41,7 @@ export function DialogMessage(props: {
|
|||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
if (!part.synthetic) agg.input += part.text
|
if (!part.synthetic) agg.input += part.text
|
||||||
}
|
}
|
||||||
if (part.type === "file") agg.parts.push(part)
|
if (part.type === "file") agg.parts.push(strip(part))
|
||||||
return agg
|
return agg
|
||||||
},
|
},
|
||||||
{ input: "", parts: [] as PromptInfo["parts"] },
|
{ input: "", parts: [] as PromptInfo["parts"] },
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export namespace Workspace {
|
|||||||
await parseSSE(res.body, stop, (event) => {
|
await parseSSE(res.body, stop, (event) => {
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: space.id,
|
directory: space.id,
|
||||||
payload: event,
|
payload: event as { type: string; properties: Record<string, unknown> },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Wait 250ms and retry if SSE connection fails
|
// Wait 250ms and retry if SSE connection fails
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
import { Effect, Exit, Fiber, Layer, LayerMap, MutableHashMap, Scope, ServiceMap } from "effect"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
import { File } from "@/file"
|
import { File } from "@/file"
|
||||||
import { FileTime } from "@/file/time"
|
import { FileTime } from "@/file/time"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
@@ -16,6 +17,7 @@ import { registerDisposer } from "./instance-registry"
|
|||||||
export { InstanceContext } from "./instance-context"
|
export { InstanceContext } from "./instance-context"
|
||||||
|
|
||||||
export type InstanceServices =
|
export type InstanceServices =
|
||||||
|
| Bus.Service
|
||||||
| Question.Service
|
| Question.Service
|
||||||
| PermissionNext.Service
|
| PermissionNext.Service
|
||||||
| ProviderAuth.Service
|
| ProviderAuth.Service
|
||||||
@@ -36,6 +38,7 @@ export type InstanceServices =
|
|||||||
function lookup(_key: string) {
|
function lookup(_key: string) {
|
||||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
|
Layer.fresh(Bus.layer),
|
||||||
Layer.fresh(Question.layer),
|
Layer.fresh(Question.layer),
|
||||||
Layer.fresh(PermissionNext.layer),
|
Layer.fresh(PermissionNext.layer),
|
||||||
Layer.fresh(ProviderAuth.defaultLayer),
|
Layer.fresh(ProviderAuth.defaultLayer),
|
||||||
@@ -56,7 +59,23 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
|||||||
Instances,
|
Instances,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
||||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
|
||||||
|
// Force-invalidate closes the RcMap entry scope even when refCount > 0.
|
||||||
|
// Standard RcMap.invalidate bails in that case, leaving long-running
|
||||||
|
// consumer fibers orphaned. This is an upstream issue:
|
||||||
|
// https://github.com/Effect-TS/effect-smol/pull/1799
|
||||||
|
const forceInvalidate = (directory: string) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const rcMap = layerMap.rcMap
|
||||||
|
if (rcMap.state._tag === "Closed") return
|
||||||
|
const entry = MutableHashMap.get(rcMap.state.map, directory)
|
||||||
|
if (entry._tag === "None") return
|
||||||
|
MutableHashMap.remove(rcMap.state.map, directory)
|
||||||
|
if (entry.value.fiber) yield* Fiber.interrupt(entry.value.fiber)
|
||||||
|
yield* Scope.close(entry.value.scope, Exit.void)
|
||||||
|
}).pipe(Effect.uninterruptible, Effect.ignore)
|
||||||
|
|
||||||
|
const unregister = registerDisposer((directory) => Effect.runPromise(forceInvalidate(directory)))
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
||||||
return Instances.of(layerMap)
|
return Instances.of(layerMap)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceSer
|
|||||||
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function runCallbackInstance<A, E>(
|
||||||
|
effect: Effect.Effect<A, E, InstanceServices>,
|
||||||
|
): (interruptor?: number) => void {
|
||||||
|
return runtime.runCallback(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||||
|
}
|
||||||
|
|
||||||
export function disposeRuntime() {
|
export function disposeRuntime() {
|
||||||
return runtime.dispose()
|
return runtime.dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { InstanceContext } from "@/effect/instance-context"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { mergeDeep } from "remeda"
|
import { mergeDeep } from "remeda"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { File } from "../file"
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
@@ -27,6 +25,7 @@ export namespace Format {
|
|||||||
export type Status = z.infer<typeof Status>
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
readonly run: (filepath: string) => Effect.Effect<void>
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,48 +89,44 @@ export namespace Format {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.acquireRelease(
|
const run = Effect.fn("Format.run")(function* (filepath: string) {
|
||||||
Effect.sync(() =>
|
log.info("formatting", { file: filepath })
|
||||||
Bus.subscribe(
|
const ext = path.extname(filepath)
|
||||||
File.Event.Edited,
|
|
||||||
Instance.bind(async (payload) => {
|
|
||||||
const file = payload.properties.file
|
|
||||||
log.info("formatting", { file })
|
|
||||||
const ext = path.extname(file)
|
|
||||||
|
|
||||||
for (const item of await getFormatter(ext)) {
|
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
||||||
log.info("running", { command: item.command })
|
log.info("running", { command: item.command })
|
||||||
try {
|
yield* Effect.tryPromise({
|
||||||
const proc = Process.spawn(
|
try: async () => {
|
||||||
item.command.map((x) => x.replace("$FILE", file)),
|
const proc = Process.spawn(
|
||||||
{
|
item.command.map((x) => x.replace("$FILE", filepath)),
|
||||||
cwd: instance.directory,
|
{
|
||||||
env: { ...process.env, ...item.environment },
|
cwd: instance.directory,
|
||||||
stdout: "ignore",
|
env: { ...process.env, ...item.environment },
|
||||||
stderr: "ignore",
|
stdout: "ignore",
|
||||||
},
|
stderr: "ignore",
|
||||||
)
|
},
|
||||||
const exit = await proc.exited
|
)
|
||||||
if (exit !== 0) {
|
const exit = await proc.exited
|
||||||
log.error("failed", {
|
if (exit !== 0) {
|
||||||
command: item.command,
|
log.error("failed", {
|
||||||
...item.environment,
|
command: item.command,
|
||||||
})
|
...item.environment,
|
||||||
}
|
})
|
||||||
} catch (error) {
|
|
||||||
log.error("failed to format file", {
|
|
||||||
error,
|
|
||||||
command: item.command,
|
|
||||||
...item.environment,
|
|
||||||
file,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
),
|
catch: (error) => {
|
||||||
),
|
log.error("failed to format file", {
|
||||||
(unsubscribe) => Effect.sync(unsubscribe),
|
error,
|
||||||
)
|
command: item.command,
|
||||||
|
...item.environment,
|
||||||
|
file: filepath,
|
||||||
|
})
|
||||||
|
return error
|
||||||
|
},
|
||||||
|
}).pipe(Effect.ignore)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|
||||||
const status = Effect.fn("Format.status")(function* () {
|
const status = Effect.fn("Format.status")(function* () {
|
||||||
@@ -147,10 +142,14 @@ export namespace Format {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ status })
|
return Service.of({ run, status })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export async function run(filepath: string) {
|
||||||
|
return runPromiseInstance(Service.use((s) => s.run(filepath)))
|
||||||
|
}
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromiseInstance(Service.use((s) => s.status()))
|
return runPromiseInstance(Service.use((s) => s.status()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-au
|
|||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
const BUILTIN = ["opencode-anthropic-auth@0.0.13"]
|
|
||||||
|
|
||||||
// Built-in plugins that are directly imported (not installed from npm)
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
||||||
|
|
||||||
@@ -55,9 +53,6 @@ export namespace Plugin {
|
|||||||
|
|
||||||
let plugins = config.plugin ?? []
|
let plugins = config.plugin ?? []
|
||||||
if (plugins.length) await Config.waitForDependencies()
|
if (plugins.length) await Config.waitForDependencies()
|
||||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
|
||||||
plugins = [...BUILTIN, ...plugins]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let plugin of plugins) {
|
for (let plugin of plugins) {
|
||||||
// ignore old codex plugin since it is supported first party now
|
// ignore old codex plugin since it is supported first party now
|
||||||
|
|||||||
@@ -150,8 +150,7 @@ export namespace Provider {
|
|||||||
autoload: false,
|
autoload: false,
|
||||||
options: {
|
options: {
|
||||||
headers: {
|
headers: {
|
||||||
"anthropic-beta":
|
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||||
"claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,18 +207,12 @@ export namespace LLM {
|
|||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
abortSignal: input.abort,
|
abortSignal: input.abort,
|
||||||
headers: {
|
headers: {
|
||||||
...(input.model.providerID.startsWith("opencode")
|
...(input.model.providerID.startsWith("opencode") && {
|
||||||
? {
|
"x-opencode-project": Instance.project.id,
|
||||||
"x-opencode-project": Instance.project.id,
|
"x-opencode-session": input.sessionID,
|
||||||
"x-opencode-session": input.sessionID,
|
"x-opencode-request": input.user.id,
|
||||||
"x-opencode-request": input.user.id,
|
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
||||||
"x-opencode-client": Flag.OPENCODE_CLIENT,
|
}),
|
||||||
}
|
|
||||||
: input.model.providerID !== "anthropic"
|
|
||||||
? {
|
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
|
||||||
}
|
|
||||||
: undefined),
|
|
||||||
...input.model.headers,
|
...input.model.headers,
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
|
||||||
|
|
||||||
IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.
|
|
||||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
|
||||||
|
|
||||||
If the user asks for help or wants to give feedback inform them of the following:
|
|
||||||
- /help: Get help with using Claude Code
|
|
||||||
- To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues
|
|
||||||
|
|
||||||
When the user directly asks about Claude Code (eg. "can Claude Code do...", "does Claude Code have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific Claude Code feature (eg. implement a hook, or write a slash command), use the WebFetch tool to gather information to answer the question from Claude Code docs. The list of available docs is available at https://docs.claude.com/en/docs/claude-code/claude_code_docs_map.md.
|
|
||||||
|
|
||||||
# Tone and style
|
|
||||||
You should be concise, direct, and to the point, while providing complete information and matching the level of detail you provide in your response with the level of complexity of the user's query or the work you have completed.
|
|
||||||
A concise response is generally less than 4 lines, not including tool calls or code generated. You should provide more detail when the task is complex or when the user asks you to.
|
|
||||||
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
|
|
||||||
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
|
|
||||||
Do not add additional code explanation summary unless requested by the user. After working on a file, briefly confirm that you have completed the task, rather than providing an explanation of what you did.
|
|
||||||
Answer the user's question directly, avoiding any elaboration, explanation, introduction, conclusion, or excessive details. Brief answers are best, but be sure to provide complete information. You MUST avoid extra preamble before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
|
|
||||||
|
|
||||||
Here are some examples to demonstrate appropriate verbosity:
|
|
||||||
<example>
|
|
||||||
user: 2 + 2
|
|
||||||
assistant: 4
|
|
||||||
</example>
|
|
||||||
|
|
||||||
<example>
|
|
||||||
user: what is 2+2?
|
|
||||||
assistant: 4
|
|
||||||
</example>
|
|
||||||
|
|
||||||
<example>
|
|
||||||
user: is 11 a prime number?
|
|
||||||
assistant: Yes
|
|
||||||
</example>
|
|
||||||
|
|
||||||
<example>
|
|
||||||
user: what command should I run to list files in the current directory?
|
|
||||||
assistant: ls
|
|
||||||
</example>
|
|
||||||
|
|
||||||
<example>
|
|
||||||
user: what command should I run to watch files in the current directory?
|
|
||||||
assistant: [runs ls to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
|
|
||||||
npm run dev
|
|
||||||
</example>
|
|
||||||
|
|
||||||
<example>
|
|
||||||
user: How many golf balls fit inside a jetta?
|
|
||||||
assistant: 150000
|
|
||||||
</example>
|
|
||||||
|
|
||||||
<example>
|
|
||||||
user: what files are in the directory src/?
|
|
||||||
assistant: [runs ls and sees foo.c, bar.c, baz.c]
|
|
||||||
user: which file contains the implementation of foo?
|
|
||||||
assistant: src/foo.c
|
|
||||||
</example>
|
|
||||||
When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
|
|
||||||
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
|
||||||
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
|
|
||||||
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
|
|
||||||
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
|
||||||
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface.
|
|
||||||
|
|
||||||
# Proactiveness
|
|
||||||
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
|
|
||||||
- Doing the right thing when asked, including taking actions and follow-up actions
|
|
||||||
- Not surprising the user with actions you take without asking
|
|
||||||
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
|
|
||||||
|
|
||||||
# Professional objectivity
|
|
||||||
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
|
|
||||||
|
|
||||||
# Task Management
|
|
||||||
You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
|
|
||||||
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
|
|
||||||
|
|
||||||
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
<example>
|
|
||||||
user: Run the build and fix any type errors
|
|
||||||
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
|
|
||||||
- Run the build
|
|
||||||
- Fix any type errors
|
|
||||||
|
|
||||||
I'm now going to run the build using Bash.
|
|
||||||
|
|
||||||
Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
|
|
||||||
|
|
||||||
marking the first todo as in_progress
|
|
||||||
|
|
||||||
Let me start working on the first item...
|
|
||||||
|
|
||||||
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
|
|
||||||
..
|
|
||||||
..
|
|
||||||
</example>
|
|
||||||
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
|
|
||||||
|
|
||||||
<example>
|
|
||||||
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
|
|
||||||
|
|
||||||
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
|
|
||||||
Adding the following todos to the todo list:
|
|
||||||
1. Research existing metrics tracking in the codebase
|
|
||||||
2. Design the metrics collection system
|
|
||||||
3. Implement core metrics tracking functionality
|
|
||||||
4. Create export functionality for different formats
|
|
||||||
|
|
||||||
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
|
|
||||||
|
|
||||||
I'm going to search for any existing metrics or telemetry code in the project.
|
|
||||||
|
|
||||||
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
|
|
||||||
|
|
||||||
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
|
|
||||||
</example>
|
|
||||||
|
|
||||||
|
|
||||||
Users may configure 'hooks', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including <user-prompt-submit-hook>, as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration.
|
|
||||||
|
|
||||||
# Doing tasks
|
|
||||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
|
||||||
- Use the TodoWrite tool to plan the task if required
|
|
||||||
|
|
||||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
|
|
||||||
|
|
||||||
|
|
||||||
# Tool usage policy
|
|
||||||
- When doing file search, prefer to use the Task tool in order to reduce context usage.
|
|
||||||
- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description.
|
|
||||||
|
|
||||||
- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
|
|
||||||
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
|
|
||||||
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.
|
|
||||||
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
|
||||||
|
|
||||||
|
|
||||||
Here is useful information about the environment you are running in:
|
|
||||||
<env>
|
|
||||||
Working directory: /home/thdxr/dev/projects/anomalyco/opencode/packages/opencode
|
|
||||||
Is directory a git repo: Yes
|
|
||||||
Platform: linux
|
|
||||||
OS Version: Linux 6.12.4-arch1-1
|
|
||||||
Today's date: 2025-09-30
|
|
||||||
</env>
|
|
||||||
You are powered by the model named Sonnet 4.5. The exact model ID is claude-sonnet-4-5-20250929.
|
|
||||||
|
|
||||||
Assistant knowledge cutoff is January 2025.
|
|
||||||
|
|
||||||
|
|
||||||
IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.
|
|
||||||
|
|
||||||
|
|
||||||
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
|
|
||||||
|
|
||||||
# Code References
|
|
||||||
|
|
||||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
|
||||||
|
|
||||||
<example>
|
|
||||||
user: Where are errors from the client handled?
|
|
||||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
|
||||||
</example>
|
|
||||||
@@ -10,6 +10,7 @@ import { createTwoFilesPatch, diffLines } from "diff"
|
|||||||
import { assertExternalDirectory } from "./external-directory"
|
import { assertExternalDirectory } from "./external-directory"
|
||||||
import { trimDiff } from "./edit"
|
import { trimDiff } from "./edit"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
|
import { Format } from "../format"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import DESCRIPTION from "./apply_patch.txt"
|
import DESCRIPTION from "./apply_patch.txt"
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
@@ -220,6 +221,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (edited) {
|
if (edited) {
|
||||||
|
await Format.run(edited)
|
||||||
await Bus.publish(File.Event.Edited, {
|
await Bus.publish(File.Event.Edited, {
|
||||||
file: edited,
|
file: edited,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { File } from "../file"
|
|||||||
import { FileWatcher } from "../file/watcher"
|
import { FileWatcher } from "../file/watcher"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
|
import { Format } from "../format"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
@@ -71,6 +72,7 @@ export const EditTool = Tool.define("edit", {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Filesystem.write(filePath, params.newString)
|
await Filesystem.write(filePath, params.newString)
|
||||||
|
await Format.run(filePath)
|
||||||
await Bus.publish(File.Event.Edited, {
|
await Bus.publish(File.Event.Edited, {
|
||||||
file: filePath,
|
file: filePath,
|
||||||
})
|
})
|
||||||
@@ -108,6 +110,7 @@ export const EditTool = Tool.define("edit", {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await Filesystem.write(filePath, contentNew)
|
await Filesystem.write(filePath, contentNew)
|
||||||
|
await Format.run(filePath)
|
||||||
await Bus.publish(File.Event.Edited, {
|
await Bus.publish(File.Event.Edited, {
|
||||||
file: filePath,
|
file: filePath,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Bus } from "../bus"
|
|||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { FileWatcher } from "../file/watcher"
|
import { FileWatcher } from "../file/watcher"
|
||||||
import { FileTime } from "../file/time"
|
import { FileTime } from "../file/time"
|
||||||
|
import { Format } from "../format"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { trimDiff } from "./edit"
|
import { trimDiff } from "./edit"
|
||||||
@@ -42,6 +43,7 @@ export const WriteTool = Tool.define("write", {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await Filesystem.write(filepath, params.content)
|
await Filesystem.write(filepath, params.content)
|
||||||
|
await Format.run(filepath)
|
||||||
await Bus.publish(File.Event.Edited, {
|
await Bus.publish(File.Event.Edited, {
|
||||||
file: filepath,
|
file: filepath,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { Deferred, Effect, Stream } from "effect"
|
||||||
|
import z from "zod"
|
||||||
|
import { Bus } from "../../src/bus"
|
||||||
|
import { BusEvent } from "../../src/bus/bus-event"
|
||||||
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test event definitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TestEvent = {
|
||||||
|
Ping: BusEvent.define("test.ping", z.object({ value: z.number() })),
|
||||||
|
Pong: BusEvent.define("test.pong", z.object({ message: z.string() })),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function withInstance(directory: string, fn: () => Promise<void>) {
|
||||||
|
return Instance.provide({ directory, fn })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Bus", () => {
|
||||||
|
afterEach(() => Instance.disposeAll())
|
||||||
|
|
||||||
|
describe("publish + subscribe", () => {
|
||||||
|
test("subscriber receives matching events", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const received: number[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||||
|
received.push(evt.properties.value)
|
||||||
|
})
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 42 })
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 99 })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(received).toEqual([42, 99])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("subscriber does not receive events of other types", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const pings: number[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||||
|
pings.push(evt.properties.value)
|
||||||
|
})
|
||||||
|
await Bus.publish(TestEvent.Pong, { message: "hello" })
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(pings).toEqual([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("publish with no subscribers does not throw", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("multiple subscribers", () => {
|
||||||
|
test("all subscribers for same event type are called", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const a: number[] = []
|
||||||
|
const b: number[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
Bus.subscribe(TestEvent.Ping, (evt) => a.push(evt.properties.value))
|
||||||
|
Bus.subscribe(TestEvent.Ping, (evt) => b.push(evt.properties.value))
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(a).toEqual([7])
|
||||||
|
expect(b).toEqual([7])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("subscribers are called in registration order", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const order: string[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
Bus.subscribe(TestEvent.Ping, () => order.push("first"))
|
||||||
|
Bus.subscribe(TestEvent.Ping, () => order.push("second"))
|
||||||
|
Bus.subscribe(TestEvent.Ping, () => order.push("third"))
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(order).toEqual(["first", "second", "third"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("unsubscribe", () => {
|
||||||
|
test("unsubscribe stops delivery", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const received: number[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
const unsub = Bus.subscribe(TestEvent.Ping, (evt) => {
|
||||||
|
received.push(evt.properties.value)
|
||||||
|
})
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
unsub()
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(received).toEqual([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unsubscribe is idempotent", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
const unsub = Bus.subscribe(TestEvent.Ping, () => {})
|
||||||
|
unsub()
|
||||||
|
unsub() // should not throw
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unsubscribing one does not affect others", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const a: number[] = []
|
||||||
|
const b: number[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
const unsubA = Bus.subscribe(TestEvent.Ping, (evt) => a.push(evt.properties.value))
|
||||||
|
Bus.subscribe(TestEvent.Ping, (evt) => b.push(evt.properties.value))
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
unsubA()
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(a).toEqual([1])
|
||||||
|
expect(b).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("subscribeAll", () => {
|
||||||
|
test("receives events of all types", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const all: string[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
Bus.subscribeAll((evt) => {
|
||||||
|
all.push(evt.type)
|
||||||
|
})
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
await Bus.publish(TestEvent.Pong, { message: "hi" })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(all).toEqual(["test.ping", "test.pong"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("subscribeAll + typed subscribe both fire", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const typed: number[] = []
|
||||||
|
const wild: string[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
Bus.subscribe(TestEvent.Ping, (evt) => typed.push(evt.properties.value))
|
||||||
|
Bus.subscribeAll((evt) => wild.push(evt.type))
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(typed).toEqual([5])
|
||||||
|
expect(wild).toEqual(["test.ping"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("unsubscribe from subscribeAll", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const all: string[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
const unsub = Bus.subscribeAll((evt) => all.push(evt.type))
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
unsub()
|
||||||
|
await Bus.publish(TestEvent.Pong, { message: "missed" })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(all).toEqual(["test.ping"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("subscribeAll delivers InstanceDisposed on disposal", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const all: string[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
Bus.subscribeAll((evt) => {
|
||||||
|
all.push(evt.type)
|
||||||
|
})
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.disposeAll()
|
||||||
|
|
||||||
|
expect(all).toContain("test.ping")
|
||||||
|
expect(all).toContain(Bus.InstanceDisposed.type)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("manual unsubscribe suppresses InstanceDisposed", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const all: string[] = []
|
||||||
|
let unsub = () => {}
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
unsub = Bus.subscribeAll((evt) => {
|
||||||
|
all.push(evt.type)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
await Instance.disposeAll()
|
||||||
|
|
||||||
|
expect(all).not.toContain(Bus.InstanceDisposed.type)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("GlobalBus forwarding", () => {
|
||||||
|
test("publish emits to GlobalBus with directory", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const globalEvents: Array<{ directory?: string; payload: any }> = []
|
||||||
|
|
||||||
|
const handler = (evt: any) => globalEvents.push(evt)
|
||||||
|
GlobalBus.on("event", handler)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 42 })
|
||||||
|
})
|
||||||
|
|
||||||
|
const ping = globalEvents.find((e) => e.payload.type === "test.ping")
|
||||||
|
expect(ping).toBeDefined()
|
||||||
|
expect(ping!.directory).toBe(tmp.path)
|
||||||
|
expect(ping!.payload).toEqual({
|
||||||
|
type: "test.ping",
|
||||||
|
properties: { value: 42 },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
GlobalBus.off("event", handler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("instance isolation", () => {
|
||||||
|
test("subscribers in one instance do not receive events from another", async () => {
|
||||||
|
await using tmpA = await tmpdir()
|
||||||
|
await using tmpB = await tmpdir()
|
||||||
|
const eventsA: number[] = []
|
||||||
|
const eventsB: number[] = []
|
||||||
|
|
||||||
|
await withInstance(tmpA.path, async () => {
|
||||||
|
Bus.subscribe(TestEvent.Ping, (evt) => eventsA.push(evt.properties.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
await withInstance(tmpB.path, async () => {
|
||||||
|
Bus.subscribe(TestEvent.Ping, (evt) => eventsB.push(evt.properties.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
await withInstance(tmpA.path, async () => {
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
await withInstance(tmpB.path, async () => {
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(eventsA).toEqual([1])
|
||||||
|
expect(eventsB).toEqual([2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe("async subscribers", () => {
|
||||||
|
test("publish is fire-and-forget (does not await subscriber callbacks)", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const received: number[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, async () => {
|
||||||
|
Bus.subscribe(TestEvent.Ping, async (evt) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 10))
|
||||||
|
received.push(evt.properties.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
await Bus.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
// Give the async subscriber time to complete
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(received).toEqual([1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Effect service", () => {
|
||||||
|
test("subscribeAll stream receives published events", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const received: string[] = []
|
||||||
|
|
||||||
|
await withInstance(tmp.path, () =>
|
||||||
|
Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Bus.Service
|
||||||
|
const done = yield* Deferred.make<void>()
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
yield* Effect.forkScoped(
|
||||||
|
svc.subscribeAll().pipe(
|
||||||
|
Stream.runForEach((msg) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
received.push(msg.type)
|
||||||
|
if (++count >= 2) yield* Deferred.succeed(done, undefined)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Let the forked fiber start and subscribe to the PubSub
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
|
yield* svc.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
yield* svc.publish(TestEvent.Pong, { message: "hi" })
|
||||||
|
yield* Deferred.await(done)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(Bus.layer)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(received).toEqual(["test.ping", "test.pong"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("subscribeAll stream ends with ensuring when scope closes", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
let ensuringFired = false
|
||||||
|
|
||||||
|
await withInstance(tmp.path, () =>
|
||||||
|
Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Bus.Service
|
||||||
|
|
||||||
|
yield* Effect.forkScoped(
|
||||||
|
svc.subscribeAll().pipe(
|
||||||
|
Stream.runForEach(() => Effect.void),
|
||||||
|
Effect.ensuring(Effect.sync(() => {
|
||||||
|
ensuringFired = true
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* svc.publish(TestEvent.Ping, { value: 1 })
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(Bus.layer)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ensuringFired).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
|
||||||
|
import { assign, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
|
||||||
|
|
||||||
|
describe("prompt part", () => {
|
||||||
|
test("strip removes persisted ids from reused file parts", () => {
|
||||||
|
const part = {
|
||||||
|
id: "prt_old",
|
||||||
|
sessionID: "ses_old",
|
||||||
|
messageID: "msg_old",
|
||||||
|
type: "file" as const,
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "tiny.png",
|
||||||
|
url: "data:image/png;base64,abc",
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(strip(part)).toEqual({
|
||||||
|
type: "file",
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "tiny.png",
|
||||||
|
url: "data:image/png;base64,abc",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("assign overwrites stale runtime ids", () => {
|
||||||
|
const part = {
|
||||||
|
id: "prt_old",
|
||||||
|
sessionID: "ses_old",
|
||||||
|
messageID: "msg_old",
|
||||||
|
type: "file" as const,
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "tiny.png",
|
||||||
|
url: "data:image/png;base64,abc",
|
||||||
|
} as PromptInfo["parts"][number]
|
||||||
|
|
||||||
|
const next = assign(part)
|
||||||
|
|
||||||
|
expect(next.id).not.toBe("prt_old")
|
||||||
|
expect(next.id.startsWith("prt_")).toBe(true)
|
||||||
|
expect(next).toMatchObject({
|
||||||
|
type: "file",
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "tiny.png",
|
||||||
|
url: "data:image/png;base64,abc",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,9 +5,9 @@ import path from "path"
|
|||||||
import { Deferred, Effect, Option } from "effect"
|
import { Deferred, Effect, Option } from "effect"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||||
|
import { Bus } from "../../src/bus"
|
||||||
import { FileWatcher } from "../../src/file/watcher"
|
import { FileWatcher } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
|
||||||
|
|
||||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
|
||||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||||
|
|
||||||
/** Run `body` with a live FileWatcher service. */
|
/** Run `body` with a live FileWatcher service. */
|
||||||
@@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
|||||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||||
let done = false
|
let done = false
|
||||||
|
|
||||||
function on(evt: BusUpdate) {
|
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
|
||||||
if (done) return
|
if (done) return
|
||||||
if (evt.directory !== directory) return
|
if (!check(evt.properties)) return
|
||||||
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
hit(evt.properties)
|
||||||
if (!check(evt.payload.properties)) return
|
})
|
||||||
hit(evt.payload.properties)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
return () => {
|
||||||
if (done) return
|
if (done) return
|
||||||
done = true
|
done = true
|
||||||
GlobalBus.off("event", on)
|
unsub()
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalBus.on("event", on)
|
|
||||||
return cleanup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||||
|
|||||||
@@ -307,8 +307,6 @@ For custom inference profiles, use the model and provider name in the key and se
|
|||||||
```txt
|
```txt
|
||||||
┌ Select auth method
|
┌ Select auth method
|
||||||
│
|
│
|
||||||
│ Claude Pro/Max
|
|
||||||
│ Create an API Key
|
|
||||||
│ Manually enter API Key
|
│ Manually enter API Key
|
||||||
└
|
└
|
||||||
```
|
```
|
||||||
@@ -320,14 +318,19 @@ For custom inference profiles, use the model and provider name in the key and se
|
|||||||
```
|
```
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
Using your Claude Pro/Max subscription in OpenCode is not officially supported by [Anthropic](https://anthropic.com).
|
There are plugins that allow you to use your Claude Pro/Max models with
|
||||||
:::
|
OpenCode. Anthropic explicitly prohibits this.
|
||||||
|
|
||||||
##### Using API keys
|
Previous versions of OpenCode came bundled with these plugins but that is no
|
||||||
|
longer the case as of 1.3.0
|
||||||
|
|
||||||
You can also select **Create an API Key** if you don't have a Pro/Max subscription. It'll also open your browser and ask you to login to Anthropic and give you a code you can paste in your terminal.
|
Other companies support freedom of choice with developer tooling - you can use
|
||||||
|
the following subscriptions in OpenCode with zero setup:
|
||||||
|
|
||||||
Or if you already have an API key, you can select **Manually enter API Key** and paste it in your terminal.
|
- ChatGPT Plus
|
||||||
|
- Github Copilot
|
||||||
|
- Gitlab Duo
|
||||||
|
:::
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user