mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-06 22:14:51 +00:00
Compare commits
15 Commits
opencode-r
...
kit/tui-sc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4175191d8 | ||
|
|
650d0dbe54 | ||
|
|
a5ec741cff | ||
|
|
fff98636f7 | ||
|
|
c72642dd35 | ||
|
|
f2d4ced8ea | ||
|
|
ae7e2eb3fb | ||
|
|
a32ffaba35 | ||
|
|
a4e75a0794 | ||
|
|
35350b1d25 | ||
|
|
263dcf75b5 | ||
|
|
7994dce0f2 | ||
|
|
fbfa148e4e | ||
|
|
9d57f21f9f | ||
|
|
3deee3a02b |
34
.github/workflows/test.yml
vendored
34
.github/workflows/test.yml
vendored
@@ -15,6 +15,7 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
@@ -45,14 +46,39 @@ jobs:
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules/.cache/turbo
|
||||
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
|
||||
turbo-${{ runner.os }}-
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun turbo test
|
||||
run: bun turbo test:ci
|
||||
env:
|
||||
# Bun 1.3.11 intermittently crashes on Windows during test teardown
|
||||
# inside the native @parcel/watcher binding. Unit CI does not rely on
|
||||
# the live watcher backend there, so disable it for that platform.
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Publish unit reports
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
with:
|
||||
report_paths: packages/*/.artifacts/unit/junit.xml
|
||||
check_name: "unit results (${{ matrix.settings.name }})"
|
||||
detailed_summary: true
|
||||
include_time_in_summary: true
|
||||
fail_on_failure: false
|
||||
|
||||
- name: Upload unit artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
path: packages/*/.artifacts/unit/junit.xml
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
|
||||
88
packages/app/e2e/prompt/prompt-footer-focus.spec.ts
Normal file
88
packages/app/e2e/prompt/prompt-footer-focus.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Locator, Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
|
||||
|
||||
type Probe = {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string; name?: string }
|
||||
models?: Array<{ providerID: string; modelID: string; name: string }>
|
||||
agents?: Array<{ name: string }>
|
||||
}
|
||||
|
||||
async function probe(page: Page): Promise<Probe | null> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
current?: Probe
|
||||
}
|
||||
}
|
||||
}
|
||||
return win.__opencode_e2e?.model?.current ?? null
|
||||
})
|
||||
}
|
||||
|
||||
async function state(page: Page) {
|
||||
const value = await probe(page)
|
||||
if (!value) throw new Error("Failed to resolve model selection probe")
|
||||
return value
|
||||
}
|
||||
|
||||
async function ready(page: Page) {
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await expect(prompt).toBeFocused()
|
||||
await prompt.pressSequentially("focus")
|
||||
return prompt
|
||||
}
|
||||
|
||||
async function body(prompt: Locator) {
|
||||
return prompt.evaluate((el) => (el as HTMLElement).innerText)
|
||||
}
|
||||
|
||||
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const prompt = await ready(page)
|
||||
|
||||
const info = await state(page)
|
||||
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
|
||||
test.skip(!next, "only one agent available")
|
||||
if (!next) return
|
||||
|
||||
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
|
||||
|
||||
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.click({ force: true })
|
||||
|
||||
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
|
||||
next,
|
||||
)
|
||||
await expect(prompt).toBeFocused()
|
||||
await prompt.pressSequentially(" agent")
|
||||
await expect.poll(() => body(prompt)).toContain("focus agent")
|
||||
})
|
||||
|
||||
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const prompt = await ready(page)
|
||||
|
||||
const info = await state(page)
|
||||
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
|
||||
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
|
||||
test.skip(!next, "only one model available")
|
||||
if (!next) return
|
||||
|
||||
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
|
||||
|
||||
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.click({ force: true })
|
||||
|
||||
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
|
||||
await expect(prompt).toBeFocused()
|
||||
await prompt.pressSequentially(" model")
|
||||
await expect.poll(() => body(prompt)).toContain("focus model")
|
||||
})
|
||||
@@ -15,6 +15,7 @@
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit",
|
||||
"test:ci": "bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
|
||||
@@ -86,6 +86,7 @@ const ModelList: Component<{
|
||||
}
|
||||
|
||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||
|
||||
export function ModelSelectorPopover(props: {
|
||||
provider?: string
|
||||
@@ -93,25 +94,31 @@ export function ModelSelectorPopover(props: {
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
onClose?: (cause: "escape" | "select") => void
|
||||
}) {
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
dismiss: "escape" | "outside" | null
|
||||
dismiss: Dismiss | null
|
||||
}>({
|
||||
open: false,
|
||||
dismiss: null,
|
||||
})
|
||||
const dialog = useDialog()
|
||||
|
||||
const handleManage = () => {
|
||||
const close = (dismiss: Dismiss) => {
|
||||
setStore("dismiss", dismiss)
|
||||
setStore("open", false)
|
||||
}
|
||||
|
||||
const handleManage = () => {
|
||||
close("manage")
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModels />)
|
||||
})
|
||||
}
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
setStore("open", false)
|
||||
close("provider")
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
@@ -136,21 +143,19 @@ export function ModelSelectorPopover(props: {
|
||||
<Kobalte.Content
|
||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
onEscapeKeyDown={(event) => {
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
close("escape")
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerDownOutside={() => {
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}}
|
||||
onFocusOutside={() => {
|
||||
setStore("dismiss", "outside")
|
||||
setStore("open", false)
|
||||
}}
|
||||
onPointerDownOutside={() => close("outside")}
|
||||
onFocusOutside={() => close("outside")}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (store.dismiss === "outside") event.preventDefault()
|
||||
const dismiss = store.dismiss
|
||||
if (dismiss === "outside") event.preventDefault()
|
||||
if (dismiss === "escape" || dismiss === "select") {
|
||||
event.preventDefault()
|
||||
props.onClose?.(dismiss)
|
||||
}
|
||||
setStore("dismiss", null)
|
||||
}}
|
||||
>
|
||||
@@ -158,7 +163,7 @@ export function ModelSelectorPopover(props: {
|
||||
<ModelList
|
||||
provider={props.provider}
|
||||
model={props.model}
|
||||
onSelect={() => setStore("open", false)}
|
||||
onSelect={() => close("select")}
|
||||
class="p-1"
|
||||
action={
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -243,23 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status()?.type !== "idle")
|
||||
const tip = () => {
|
||||
if (working()) {
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.stop")}</span>
|
||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const imageAttachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -297,6 +280,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (store.mode === "shell") return 0
|
||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||
})
|
||||
const blank = createMemo(() => {
|
||||
const text = prompt
|
||||
.current()
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
|
||||
})
|
||||
const stopping = createMemo(() => working() && blank())
|
||||
const tip = () => {
|
||||
if (stopping()) {
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.stop")}</span>
|
||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const contextItems = createMemo(() => {
|
||||
const items = prompt.context.items()
|
||||
@@ -502,6 +510,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return getCursorPosition(editorRef)
|
||||
}
|
||||
|
||||
const restoreFocus = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||
editorRef.focus()
|
||||
setCursorPosition(editorRef, cursor)
|
||||
queueScroll()
|
||||
})
|
||||
}
|
||||
|
||||
const renderEditorWithCursor = (parts: Prompt) => {
|
||||
const cursor = currentCursor()
|
||||
renderEditor(parts)
|
||||
@@ -1398,17 +1415,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1 pointer-events-auto">
|
||||
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||
disabled={store.mode !== "normal" || (!working() && blank())}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
icon={stopping() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={buttons()}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -1471,7 +1488,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
size="normal"
|
||||
options={agentNames()}
|
||||
current={local.agent.current()?.name ?? ""}
|
||||
onSelect={local.agent.set}
|
||||
onSelect={(value) => {
|
||||
local.agent.set(value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
@@ -1535,6 +1555,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={restoreFocus}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
@@ -1563,7 +1584,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
options={variants()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
onSelect={(value) => {
|
||||
local.model.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] text-text-base"
|
||||
valueClass="truncate text-13-regular text-text-base"
|
||||
triggerStyle={control()}
|
||||
|
||||
@@ -139,11 +139,6 @@ export const SettingsGeneral: Component = () => {
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
|
||||
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
|
||||
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
|
||||
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
|
||||
])
|
||||
|
||||
const languageOptions = createMemo(() =>
|
||||
language.locales.map((locale) => ({
|
||||
value: locale,
|
||||
@@ -241,24 +236,6 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.followup.title")}
|
||||
description={language.t("settings.general.row.followup.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-followup"
|
||||
options={followupOptions()}
|
||||
current={followupOptions().find((o) => o.value === settings.general.followup())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && settings.general.setFollowup(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "min-width": "180px" }}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -248,7 +248,7 @@ export async function bootstrapDirectory(input: {
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
|
||||
@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||
})
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
||||
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
})
|
||||
|
||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||
|
||||
@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "vcs.branch.updated": {
|
||||
const props = event.properties as { branch: string }
|
||||
const props = event.properties as { branch?: string }
|
||||
if (input.store.vcs?.branch === props.branch) break
|
||||
const next = { branch: props.branch }
|
||||
const next = { ...input.store.vcs, branch: props.branch }
|
||||
input.setStore("vcs", next)
|
||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||
break
|
||||
|
||||
@@ -136,6 +136,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (store.general?.followup !== "queue") return
|
||||
setStore("general", "followup", "steer")
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
get current() {
|
||||
@@ -150,9 +155,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setReleaseNotes(value: boolean) {
|
||||
setStore("general", "releaseNotes", value)
|
||||
},
|
||||
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
|
||||
followup: withFallback(
|
||||
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
|
||||
defaultSettings.general.followup,
|
||||
),
|
||||
setFollowup(value: "queue" | "steer") {
|
||||
setStore("general", "followup", value)
|
||||
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||
},
|
||||
showReasoningSummaries: withFallback(
|
||||
() => store.general?.showReasoningSummaries,
|
||||
|
||||
@@ -535,6 +535,8 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||
"session.review.noChanges": "No changes",
|
||||
"session.review.noUncommittedChanges": "No uncommitted changes yet",
|
||||
"session.review.noBranchChanges": "No branch changes yet",
|
||||
|
||||
"session.files.selectToOpen": "Select a file to open",
|
||||
"session.files.all": "All files",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { hasProjectPermissions } from "./helpers"
|
||||
|
||||
@@ -101,42 +102,46 @@ const SessionRow = (props: {
|
||||
warmPress: () => void
|
||||
warmFocus: () => void
|
||||
cancelHoverPrefetch: () => void
|
||||
}): JSX.Element => (
|
||||
<A
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onPointerDown={props.warmPress}
|
||||
onPointerEnter={props.warmHover}
|
||||
onPointerLeave={props.cancelHoverPrefetch}
|
||||
onFocus={props.warmFocus}
|
||||
onClick={() => {
|
||||
props.setHoverSession(undefined)
|
||||
if (props.sidebarOpened()) return
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
}) => {
|
||||
const title = () => sessionTitle(props.session.title)
|
||||
|
||||
return (
|
||||
<A
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onPointerDown={props.warmPress}
|
||||
onPointerEnter={props.warmHover}
|
||||
onPointerLeave={props.cancelHoverPrefetch}
|
||||
onFocus={props.warmFocus}
|
||||
onClick={() => {
|
||||
props.setHoverSession(undefined)
|
||||
if (props.sidebarOpened()) return
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
|
||||
</A>
|
||||
)
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
|
||||
</A>
|
||||
)
|
||||
}
|
||||
|
||||
const SessionHoverPreview = (props: {
|
||||
mobile?: boolean
|
||||
@@ -319,7 +324,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={props.session.title}
|
||||
value={sessionTitle(props.session.title)}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
@@ -68,6 +68,9 @@ type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
const emptyFollowups: FollowupItem[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "session" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
@@ -427,15 +430,16 @@ export default function Page() {
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||
const canReview = createMemo(() => !!sync.project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -458,6 +462,12 @@ export default function Page() {
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasSessionReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
@@ -511,11 +521,22 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
changes: "git" as ChangeMode,
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
const [vcs, setVcs] = createStore({
|
||||
diff: {
|
||||
git: [] as FileDiff[],
|
||||
branch: [] as FileDiff[],
|
||||
},
|
||||
ready: {
|
||||
git: false,
|
||||
branch: false,
|
||||
},
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = persisted(
|
||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||
createStore<{
|
||||
@@ -549,6 +570,68 @@ export default function Page() {
|
||||
let todoTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
const vcsTask = new Map<VcsMode, Promise<void>>()
|
||||
const vcsRun = new Map<VcsMode, number>()
|
||||
|
||||
const bumpVcs = (mode: VcsMode) => {
|
||||
const next = (vcsRun.get(mode) ?? 0) + 1
|
||||
vcsRun.set(mode, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const resetVcs = (mode?: VcsMode) => {
|
||||
const list = mode ? [mode] : (["git", "branch"] as const)
|
||||
list.forEach((item) => {
|
||||
bumpVcs(item)
|
||||
vcsTask.delete(item)
|
||||
setVcs("diff", item, [])
|
||||
setVcs("ready", item, false)
|
||||
})
|
||||
}
|
||||
|
||||
const loadVcs = (mode: VcsMode, force = false) => {
|
||||
if (sync.project?.vcs !== "git") return Promise.resolve()
|
||||
if (!force && vcs.ready[mode]) return Promise.resolve()
|
||||
|
||||
if (force) {
|
||||
if (vcsTask.has(mode)) bumpVcs(mode)
|
||||
vcsTask.delete(mode)
|
||||
setVcs("ready", mode, false)
|
||||
}
|
||||
|
||||
const current = vcsTask.get(mode)
|
||||
if (current) return current
|
||||
|
||||
const run = bumpVcs(mode)
|
||||
|
||||
const task = sdk.client.vcs
|
||||
.diff({ mode })
|
||||
.then((result) => {
|
||||
if (vcsRun.get(mode) !== run) return
|
||||
setVcs("diff", mode, result.data ?? [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (vcsRun.get(mode) !== run) return
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
setVcs("diff", mode, [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
|
||||
})
|
||||
|
||||
vcsTask.set(mode, task)
|
||||
return task
|
||||
}
|
||||
|
||||
const refreshVcs = () => {
|
||||
resetVcs()
|
||||
const mode = untrack(vcsMode)
|
||||
if (!mode) return
|
||||
if (!untrack(wantsReview)) return
|
||||
void loadVcs(mode, true)
|
||||
}
|
||||
|
||||
createComputed((prev) => {
|
||||
const open = desktopReviewOpen()
|
||||
@@ -564,7 +647,42 @@ export default function Page() {
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
if (sync.project?.vcs === "git") list.push("git")
|
||||
if (
|
||||
sync.project?.vcs === "git" &&
|
||||
sync.data.vcs?.branch &&
|
||||
sync.data.vcs?.default_branch &&
|
||||
sync.data.vcs.branch !== sync.data.vcs.default_branch
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("session", "turn")
|
||||
return list
|
||||
})
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||
})
|
||||
const reviewDiffs = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git
|
||||
if (store.changes === "branch") return vcs.diff.branch
|
||||
if (store.changes === "session") return diffs()
|
||||
return turnDiffs()
|
||||
})
|
||||
const reviewCount = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git.length
|
||||
if (store.changes === "branch") return vcs.diff.branch.length
|
||||
if (store.changes === "session") return sessionCount()
|
||||
return turnDiffs().length
|
||||
})
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const reviewReady = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.ready.git
|
||||
if (store.changes === "branch") return vcs.ready.branch
|
||||
if (store.changes === "session") return !hasSessionReview() || diffsReady()
|
||||
return true
|
||||
})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
@@ -630,13 +748,7 @@ export default function Page() {
|
||||
scrollToMessage(msgs[targetIndex], "auto")
|
||||
}
|
||||
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
const sessionEmptyKey = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (project && !project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
@@ -790,13 +902,46 @@ export default function Page() {
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "session")
|
||||
setStore("changes", "git")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => {
|
||||
resetVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
|
||||
(next, prev) => {
|
||||
if (prev === undefined || same(next, prev)) return
|
||||
refreshVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const stopVcs = sdk.event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
refreshVcs()
|
||||
})
|
||||
onCleanup(stopVcs)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.dir,
|
||||
@@ -919,6 +1064,40 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||
const wantsReview = createMemo(() =>
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const list = changesOptions()
|
||||
if (list.includes(store.changes)) return
|
||||
const next = list[0]
|
||||
if (!next) return
|
||||
setStore("changes", next)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
void loadVcs(mode)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||
(next, prev) => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||
void loadVcs(mode, true)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||
@@ -965,21 +1144,23 @@ export default function Page() {
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
const changesOptions = ["session", "turn"] as const
|
||||
const changesOptionsList = [...changesOptions]
|
||||
|
||||
const changesTitle = () => {
|
||||
if (!hasReview()) {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
if (option === "session") return language.t("ui.sessionReview.title")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={changesOptionsList}
|
||||
options={changesOptions()}
|
||||
current={store.changes}
|
||||
label={(option) =>
|
||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
label={label}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
@@ -988,20 +1169,34 @@ export default function Page() {
|
||||
)
|
||||
}
|
||||
|
||||
const emptyTurn = () => (
|
||||
const empty = (text: string) => (
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "turn") return emptyTurn()
|
||||
const reviewEmptyText = createMemo(() => {
|
||||
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||
if (store.changes === "turn") return language.t("session.review.noChanges")
|
||||
return language.t(sessionEmptyKey())
|
||||
})
|
||||
|
||||
if (hasReview() && !diffsReady()) {
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "git" || store.changes === "branch") {
|
||||
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (store.changes === "turn") {
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (hasSessionReview() && !diffsReady()) {
|
||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
|
||||
if (reviewEmptyKey() === "session.review.noVcs") {
|
||||
if (sessionEmptyKey() === "session.review.noVcs") {
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -1021,7 +1216,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1128,7 +1323,7 @@ export default function Page() {
|
||||
const pending = tree.pendingDiff
|
||||
if (!pending) return
|
||||
if (!tree.reviewScroll) return
|
||||
if (!diffsReady()) return
|
||||
if (!reviewReady()) return
|
||||
|
||||
const attempt = (count: number) => {
|
||||
if (tree.pendingDiff !== pending) return
|
||||
@@ -1169,10 +1364,7 @@ export default function Page() {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
|
||||
const wants = isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes"
|
||||
if (!wants) return
|
||||
if (!wantsReview()) return
|
||||
if (sync.data.session_diff[id] !== undefined) return
|
||||
if (sync.status === "loading") return
|
||||
|
||||
@@ -1181,13 +1373,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
[
|
||||
sessionKey(),
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
] as const,
|
||||
() => [sessionKey(), wantsReview()] as const,
|
||||
([key, wants]) => {
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
@@ -1867,6 +2053,12 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { Index, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { composerEnabled, composerProbe } from "@/testing/session-composer"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -210,76 +210,25 @@ export function SessionTodoDock(props: {
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
<TodoList todos={props.todos} />
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
function TodoList(props: { todos: Todo[] }) {
|
||||
const [store, setStore] = createStore({
|
||||
stuck: false,
|
||||
scrolling: false,
|
||||
})
|
||||
let scrollRef!: HTMLDivElement
|
||||
let timer: number | undefined
|
||||
|
||||
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
|
||||
|
||||
const ensure = () => {
|
||||
if (!props.open) return
|
||||
if (store.scrolling) return
|
||||
if (!scrollRef || scrollRef.offsetParent === null) return
|
||||
|
||||
const el = scrollRef.querySelector("[data-in-progress]")
|
||||
if (!(el instanceof HTMLElement)) return
|
||||
|
||||
const topFade = 16
|
||||
const bottomFade = 44
|
||||
const container = scrollRef.getBoundingClientRect()
|
||||
const rect = el.getBoundingClientRect()
|
||||
const top = rect.top - container.top + scrollRef.scrollTop
|
||||
const bottom = rect.bottom - container.top + scrollRef.scrollTop
|
||||
const viewTop = scrollRef.scrollTop + topFade
|
||||
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
|
||||
|
||||
if (top < viewTop) {
|
||||
scrollRef.scrollTop = Math.max(0, top - topFade)
|
||||
} else if (bottom > viewBottom) {
|
||||
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
|
||||
}
|
||||
|
||||
setStore("stuck", scrollRef.scrollTop > 0)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on([() => props.open, inProgress], () => {
|
||||
if (!props.open || inProgress() < 0) return
|
||||
requestAnimationFrame(ensure)
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="relative">
|
||||
<div
|
||||
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
|
||||
ref={scrollRef}
|
||||
style={{ "overflow-anchor": "none" }}
|
||||
onScroll={(e) => {
|
||||
setStore("stuck", e.currentTarget.scrollTop > 0)
|
||||
setStore("scrolling", true)
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setStore("scrolling", false)
|
||||
if (inProgress() < 0) return
|
||||
requestAnimationFrame(ensure)
|
||||
}, 250)
|
||||
}}
|
||||
>
|
||||
<Index each={props.todos}>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
|
||||
@@ -43,7 +44,6 @@ type MessageComment = {
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
type UserActions = {
|
||||
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
||||
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
||||
@@ -291,6 +291,7 @@ export function MessageTimeline(props: {
|
||||
return sync.session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
@@ -399,7 +400,7 @@ export function MessageTimeline(props: {
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!sessionID()) return
|
||||
setTitle({ editing: true, draft: titleValue() ?? "" })
|
||||
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
@@ -417,7 +418,7 @@ export function MessageTimeline(props: {
|
||||
if (titleMutation.isPending) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (titleValue() ?? "")) {
|
||||
if (!next || next === (titleLabel() ?? "")) {
|
||||
setTitle("editing", false)
|
||||
return
|
||||
}
|
||||
@@ -532,7 +533,9 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
@@ -673,7 +676,7 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show when={titleLabel() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
@@ -681,7 +684,7 @@ export function MessageTimeline(props: {
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
{titleLabel()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -18,7 +19,6 @@ import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
@@ -26,6 +26,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => FileDiff[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
reviewCount: () => number
|
||||
reviewPanel: () => JSX.Element
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
@@ -33,12 +39,11 @@ export function SessionSidePanel(props: {
|
||||
size: Sizing
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const sync = useSync()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
@@ -53,24 +58,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.noChanges"
|
||||
})
|
||||
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -81,7 +69,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of diffs()) {
|
||||
for (const diff of props.diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
@@ -135,7 +123,7 @@ export function SessionSidePanel(props: {
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: props.canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -240,12 +228,12 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={hasReview()}>
|
||||
<div>{reviewCount()}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
@@ -304,7 +292,7 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
@@ -378,8 +366,10 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{reviewCount()}{" "}
|
||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
@@ -387,9 +377,9 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={hasReview()}>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
@@ -408,11 +398,7 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{empty(
|
||||
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{empty(props.empty())}</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
|
||||
@@ -52,11 +52,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
}
|
||||
const hasReview = () => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
|
||||
}
|
||||
const hasReview = () => !!params.id
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
|
||||
7
packages/app/src/utils/session-title.ts
Normal file
7
packages/app/src/utils/session-title.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
export function sessionTitle(title?: string) {
|
||||
if (!title) return title
|
||||
const match = title.match(pattern)
|
||||
return match?.[1] ?? title
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"prepare": "effect-language-service patch || true",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"build": "bun run script/build.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
|
||||
14
packages/opencode/specs/v2.md
Normal file
14
packages/opencode/specs/v2.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 2.0
|
||||
|
||||
What we would change if we could
|
||||
|
||||
## Keybindings vs. Keymappings
|
||||
|
||||
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like
|
||||
|
||||
```ts
|
||||
{ key: "ctrl+w", cmd: string | function, description }
|
||||
```
|
||||
|
||||
_Why_
|
||||
Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right.
|
||||
@@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { Git } from "@/git"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
|
||||
}
|
||||
|
||||
// Get repo info
|
||||
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
@@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({
|
||||
: "issue"
|
||||
: undefined
|
||||
const gitText = async (args: string[]) => {
|
||||
const result = await git(args, { cwd: Instance.worktree })
|
||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result.text().trim()
|
||||
}
|
||||
const gitRun = async (args: string[]) => {
|
||||
const result = await git(args, { cwd: Instance.worktree })
|
||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
|
||||
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
|
||||
const commitChanges = async (summary: string, actor?: string) => {
|
||||
const args = ["commit", "-m", summary]
|
||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
export const PrCommand = cmd({
|
||||
command: "pr <number>",
|
||||
@@ -67,9 +67,9 @@ export const PrCommand = cmd({
|
||||
const remoteName = forkOwner
|
||||
|
||||
// Check if remote already exists
|
||||
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
@@ -77,7 +77,7 @@ export const PrCommand = cmd({
|
||||
|
||||
// Set upstream to the fork so pushes go there
|
||||
const headRefName = prInfo.headRefName
|
||||
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ import { BashTool } from "../../tool/bash"
|
||||
import { TodoWriteTool } from "../../tool/todo"
|
||||
import { Locale } from "../../util/locale"
|
||||
|
||||
type ToolProps<T extends Tool.Info> = {
|
||||
type ToolProps<T> = {
|
||||
input: Tool.InferParameters<T>
|
||||
metadata: Tool.InferMetadata<T>
|
||||
part: ToolPart
|
||||
}
|
||||
|
||||
function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
|
||||
function props<T>(part: ToolPart): ToolProps<T> {
|
||||
const state = part.state
|
||||
return {
|
||||
input: state.input as Tool.InferParameters<T>,
|
||||
|
||||
@@ -149,7 +149,7 @@ export function Session() {
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
|
||||
@@ -1572,7 +1572,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
||||
)
|
||||
}
|
||||
|
||||
type ToolProps<T extends Tool.Info> = {
|
||||
type ToolProps<T> = {
|
||||
input: Partial<Tool.InferParameters<T>>
|
||||
metadata: Partial<Tool.InferMetadata<T>>
|
||||
permission: Record<string, any>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { git } from "@/util/git"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
@@ -419,7 +419,7 @@ export namespace File {
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const diffOutput = (
|
||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
@@ -439,7 +439,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
const untrackedOutput = (
|
||||
await git(
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
@@ -472,7 +472,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
const deletedOutput = (
|
||||
await git(
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
@@ -560,17 +560,17 @@ export namespace File {
|
||||
if (Instance.project.vcs === "git") {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
let diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
|
||||
@@ -10,8 +10,8 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { git } from "@/util/git"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Config } from "../config/config"
|
||||
import { FileIgnore } from "./ignore"
|
||||
@@ -132,7 +132,7 @@ export namespace FileWatcher {
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
git(["rev-parse", "--git-dir"], {
|
||||
Git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
|
||||
303
packages/opencode/src/git/index.ts
Normal file
303
packages/opencode/src/git/index.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Git {
|
||||
const cfg = [
|
||||
"--no-optional-locks",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
] as const
|
||||
|
||||
const out = (result: { text(): string }) => result.text().trim()
|
||||
const nuls = (text: string) => text.split("\0").filter(Boolean)
|
||||
const fail = (err: unknown) =>
|
||||
({
|
||||
exitCode: 1,
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||
}) satisfies Result
|
||||
|
||||
export type Kind = "added" | "deleted" | "modified"
|
||||
|
||||
export type Base = {
|
||||
readonly name: string
|
||||
readonly ref: string
|
||||
}
|
||||
|
||||
export type Item = {
|
||||
readonly file: string
|
||||
readonly code: string
|
||||
readonly status: Kind
|
||||
}
|
||||
|
||||
export type Stat = {
|
||||
readonly file: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: () => string
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly cwd: string
|
||||
readonly env?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
|
||||
readonly branch: (cwd: string) => Effect.Effect<string | undefined>
|
||||
readonly prefix: (cwd: string) => Effect.Effect<string>
|
||||
readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
|
||||
readonly hasHead: (cwd: string) => Effect.Effect<boolean>
|
||||
readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
|
||||
readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
|
||||
readonly status: (cwd: string) => Effect.Effect<Item[]>
|
||||
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
|
||||
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
|
||||
}
|
||||
|
||||
const kind = (code: string): Kind => {
|
||||
if (code === "??") return "added"
|
||||
if (code.includes("U")) return "modified"
|
||||
if (code.includes("A") && !code.includes("D")) return "added"
|
||||
if (code.includes("D") && !code.includes("A")) return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
const run = Effect.fn("Git.run")(
|
||||
function* (args: string[], opts: Options) {
|
||||
const proc = ChildProcess.make("git", [...cfg, ...args], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [stdout, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
return {
|
||||
exitCode: yield* handle.exitCode,
|
||||
text: () => stdout,
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
} satisfies Result
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) => Effect.succeed(fail(err))),
|
||||
)
|
||||
|
||||
const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
|
||||
return (yield* run(args, opts)).text()
|
||||
})
|
||||
|
||||
const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
|
||||
return (yield* text(args, opts))
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const refs = Effect.fnUntraced(function* (cwd: string) {
|
||||
return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
|
||||
})
|
||||
|
||||
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
|
||||
const result = yield* run(["config", "init.defaultBranch"], { cwd })
|
||||
const name = out(result)
|
||||
if (!name || !list.includes(name)) return
|
||||
return { name, ref: name } satisfies Base
|
||||
})
|
||||
|
||||
const primary = Effect.fnUntraced(function* (cwd: string) {
|
||||
const list = yield* lines(["remote"], { cwd })
|
||||
if (list.includes("origin")) return "origin"
|
||||
if (list.length === 1) return list[0]
|
||||
if (list.includes("upstream")) return "upstream"
|
||||
return list[0]
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.branch")(function* (cwd: string) {
|
||||
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
const text = out(result)
|
||||
return text || undefined
|
||||
})
|
||||
|
||||
const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
|
||||
const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
|
||||
if (result.exitCode !== 0) return ""
|
||||
return out(result)
|
||||
})
|
||||
|
||||
const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
|
||||
const remote = yield* primary(cwd)
|
||||
if (remote) {
|
||||
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
|
||||
if (head.exitCode === 0) {
|
||||
const ref = out(head).replace(/^refs\/remotes\//, "")
|
||||
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
||||
if (name) return { name, ref } satisfies Base
|
||||
}
|
||||
}
|
||||
|
||||
const list = yield* refs(cwd)
|
||||
const next = yield* configured(cwd, list)
|
||||
if (next) return next
|
||||
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
|
||||
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
|
||||
})
|
||||
|
||||
const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
|
||||
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
|
||||
return result.exitCode === 0
|
||||
})
|
||||
|
||||
const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
|
||||
const result = yield* run(["merge-base", base, head], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
const text = out(result)
|
||||
return text || undefined
|
||||
})
|
||||
|
||||
const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
|
||||
const target = prefix ? `${prefix}${file}` : file
|
||||
const result = yield* run(["show", `${ref}:${target}`], { cwd })
|
||||
if (result.exitCode !== 0) return ""
|
||||
if (result.stdout.includes(0)) return ""
|
||||
return result.text()
|
||||
})
|
||||
|
||||
const status = Effect.fn("Git.status")(function* (cwd: string) {
|
||||
return nuls(
|
||||
yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
|
||||
cwd,
|
||||
}),
|
||||
).flatMap((item) => {
|
||||
const file = item.slice(3)
|
||||
if (!file) return []
|
||||
const code = item.slice(0, 2)
|
||||
return [{ file, code, status: kind(code) } satisfies Item]
|
||||
})
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
|
||||
const list = nuls(
|
||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
|
||||
)
|
||||
return list.flatMap((code, idx) => {
|
||||
if (idx % 2 !== 0) return []
|
||||
const file = list[idx + 1]
|
||||
if (!code || !file) return []
|
||||
return [{ file, code, status: kind(code) } satisfies Item]
|
||||
})
|
||||
})
|
||||
|
||||
const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
|
||||
return nuls(
|
||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
|
||||
).flatMap((item) => {
|
||||
const a = item.indexOf("\t")
|
||||
const b = item.indexOf("\t", a + 1)
|
||||
if (a === -1 || b === -1) return []
|
||||
const file = item.slice(b + 1)
|
||||
if (!file) return []
|
||||
const adds = item.slice(0, a)
|
||||
const dels = item.slice(a + 1, b)
|
||||
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
|
||||
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
|
||||
return [
|
||||
{
|
||||
file,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
} satisfies Stat,
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
run,
|
||||
branch,
|
||||
prefix,
|
||||
defaultBranch,
|
||||
hasHead,
|
||||
mergeBase,
|
||||
show,
|
||||
status,
|
||||
diff,
|
||||
stats,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function run(args: string[], opts: Options) {
|
||||
return runPromise((git) => git.run(args, opts))
|
||||
}
|
||||
|
||||
export async function branch(cwd: string) {
|
||||
return runPromise((git) => git.branch(cwd))
|
||||
}
|
||||
|
||||
export async function prefix(cwd: string) {
|
||||
return runPromise((git) => git.prefix(cwd))
|
||||
}
|
||||
|
||||
export async function defaultBranch(cwd: string) {
|
||||
return runPromise((git) => git.defaultBranch(cwd))
|
||||
}
|
||||
|
||||
export async function hasHead(cwd: string) {
|
||||
return runPromise((git) => git.hasHead(cwd))
|
||||
}
|
||||
|
||||
export async function mergeBase(cwd: string, base: string, head?: string) {
|
||||
return runPromise((git) => git.mergeBase(cwd, base, head))
|
||||
}
|
||||
|
||||
export async function show(cwd: string, ref: string, file: string, prefix?: string) {
|
||||
return runPromise((git) => git.show(cwd, ref, file, prefix))
|
||||
}
|
||||
|
||||
export async function status(cwd: string) {
|
||||
return runPromise((git) => git.status(cwd))
|
||||
}
|
||||
|
||||
export async function diff(cwd: string, ref: string) {
|
||||
return runPromise((git) => git.diff(cwd, ref))
|
||||
}
|
||||
|
||||
export async function stats(cwd: string, ref: string) {
|
||||
return runPromise((git) => git.stats(cwd, ref))
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,111 @@
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "@/util/log"
|
||||
import { Instance } from "./instance"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Vcs {
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
const count = (text: string) => {
|
||||
if (!text) return 0
|
||||
if (!text.endsWith("\n")) return text.split("\n").length
|
||||
return text.slice(0, -1).split("\n").length
|
||||
}
|
||||
|
||||
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
||||
const full = path.join(cwd, file)
|
||||
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
||||
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
if (Buffer.from(buf).includes(0)) return ""
|
||||
return Buffer.from(buf).toString("utf8")
|
||||
})
|
||||
|
||||
const nums = (list: Git.Stat[]) =>
|
||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||
|
||||
const merge = (...lists: Git.Item[][]) => {
|
||||
const out = new Map<string, Git.Item>()
|
||||
lists.flat().forEach((item) => {
|
||||
if (!out.has(item.file)) out.set(item.file, item)
|
||||
})
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
const files = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
list: Git.Item[],
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
) {
|
||||
const base = ref ? yield* git.prefix(cwd) : ""
|
||||
const next = yield* Effect.forEach(
|
||||
list,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
||||
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
||||
const stat = map.get(item.file)
|
||||
return {
|
||||
file: item.file,
|
||||
before,
|
||||
after,
|
||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||
status: item.status,
|
||||
} satisfies Snapshot.FileDiff
|
||||
}),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
) {
|
||||
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
||||
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
||||
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string,
|
||||
) {
|
||||
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
||||
concurrency: 3,
|
||||
})
|
||||
return yield* files(
|
||||
fs,
|
||||
git,
|
||||
cwd,
|
||||
ref,
|
||||
merge(
|
||||
list,
|
||||
extra.filter((item) => item.code === "??"),
|
||||
),
|
||||
nums(stats),
|
||||
)
|
||||
})
|
||||
|
||||
export const Mode = z.enum(["git", "branch"])
|
||||
export type Mode = z.infer<typeof Mode>
|
||||
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
@@ -24,6 +118,7 @@ export namespace Vcs {
|
||||
export const Info = z
|
||||
.object({
|
||||
branch: z.string().optional(),
|
||||
default_branch: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsInfo",
|
||||
@@ -33,57 +128,45 @@ export namespace Vcs {
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||
readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
}
|
||||
|
||||
interface State {
|
||||
current: string | undefined
|
||||
root: Git.Base | undefined
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Bus.Service | ChildProcessSpawner.ChildProcessSpawner> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const bus = yield* Bus.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts: { cwd: string }) {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make("git", args, { cwd: opts.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const text = yield* Stream.mkString(Stream.decodeText(handle.stdout))
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text }
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), text: "" })),
|
||||
)
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined }
|
||||
return { current: undefined, root: undefined }
|
||||
}
|
||||
|
||||
const getBranch = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: ctx.worktree })
|
||||
if (result.code !== 0) return undefined
|
||||
const text = result.text.trim()
|
||||
return text || undefined
|
||||
const get = Effect.fnUntraced(function* () {
|
||||
return yield* git.branch(ctx.directory)
|
||||
})
|
||||
|
||||
const value = {
|
||||
current: yield* getBranch(),
|
||||
}
|
||||
log.info("initialized", { branch: value.current })
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
|
||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach(() =>
|
||||
Stream.runForEach((_evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* getBranch()
|
||||
const next = yield* get()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
@@ -106,19 +189,52 @@ export namespace Vcs {
|
||||
branch: Effect.fn("Vcs.branch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.current)
|
||||
}),
|
||||
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.root?.name)
|
||||
}),
|
||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
||||
const value = yield* InstanceState.get(state)
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
if (mode === "git") {
|
||||
return yield* track(
|
||||
fs,
|
||||
git,
|
||||
Instance.directory,
|
||||
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
if (!value.root) return []
|
||||
if (value.current && value.current === value.root.name) return []
|
||||
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
|
||||
if (!ref) return []
|
||||
return yield* compare(fs, git, Instance.directory, ref)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function init() {
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export function branch() {
|
||||
export async function branch() {
|
||||
return runPromise((svc) => svc.branch())
|
||||
}
|
||||
|
||||
export async function defaultBranch() {
|
||||
return runPromise((svc) => svc.defaultBranch())
|
||||
}
|
||||
|
||||
export async function diff(mode: Mode) {
|
||||
return runPromise((svc) => svc.diff(mode))
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -198,7 +198,7 @@ export namespace Question {
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describeRoute, resolver } from "hono-openapi"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import z from "zod"
|
||||
@@ -16,6 +16,7 @@ import { Command } from "../command"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { QuestionRoutes } from "./routes/question"
|
||||
import { PermissionRoutes } from "./routes/permission"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { ProjectRoutes } from "./routes/project"
|
||||
import { SessionRoutes } from "./routes/session"
|
||||
import { PtyRoutes } from "./routes/pty"
|
||||
@@ -134,12 +135,40 @@ export const InstanceRoutes = (app?: Hono) =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const branch = await Vcs.branch()
|
||||
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
||||
return c.json({
|
||||
branch,
|
||||
default_branch,
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/vcs/diff",
|
||||
describeRoute({
|
||||
summary: "Get VCS diff",
|
||||
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
||||
operationId: "vcs.diff",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS diff",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Snapshot.FileDiff.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
mode: Vcs.Mode,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
return c.json(await Vcs.diff(c.req.valid("query").mode))
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/command",
|
||||
describeRoute({
|
||||
|
||||
@@ -560,7 +560,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}) {
|
||||
const { task, model, lastUser, sessionID, session, msgs } = input
|
||||
const ctx = yield* InstanceState.context
|
||||
const taskTool = yield* Effect.promise(() => TaskTool.init())
|
||||
const taskTool = yield* Effect.promise(() => registry.named.task.init())
|
||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
@@ -583,7 +583,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
sessionID: assistantMessage.sessionID,
|
||||
type: "tool",
|
||||
callID: ulid(),
|
||||
tool: TaskTool.id,
|
||||
tool: registry.named.task.id,
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
@@ -1110,7 +1110,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||
},
|
||||
]
|
||||
const read = yield* Effect.promise(() => ReadTool.init()).pipe(
|
||||
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
|
||||
Effect.flatMap((t) =>
|
||||
provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
||||
Effect.flatMap((mdl) =>
|
||||
@@ -1174,7 +1174,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
if (part.mime === "application/x-directory") {
|
||||
const args = { filePath: filepath }
|
||||
const result = yield* Effect.promise(() => ReadTool.init()).pipe(
|
||||
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
|
||||
Effect.flatMap((t) =>
|
||||
Effect.promise(() =>
|
||||
t.execute(args, {
|
||||
|
||||
@@ -82,7 +82,7 @@ export namespace Todo {
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function update(input: { sessionID: SessionID; todos: Info[] }) {
|
||||
|
||||
@@ -3,10 +3,10 @@ import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import { git } from "@/util/git"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
|
||||
import { Git } from "@/git"
|
||||
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
@@ -111,7 +111,7 @@ export namespace Storage {
|
||||
if (!worktree) continue
|
||||
if (!(yield* fs.isDir(worktree))) continue
|
||||
const result = yield* Effect.promise(() =>
|
||||
git(["rev-list", "--max-parents=0", "--all"], {
|
||||
Git.run(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: worktree,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
import { Question } from "../question"
|
||||
import DESCRIPTION from "./question.txt"
|
||||
|
||||
export const QuestionTool = Tool.define("question", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const answers = await Question.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
questions: params.questions,
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
const parameters = z.object({
|
||||
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
|
||||
})
|
||||
|
||||
function format(answer: Question.Answer | undefined) {
|
||||
if (!answer?.length) return "Unanswered"
|
||||
return answer.join(", ")
|
||||
}
|
||||
type Metadata = {
|
||||
answers: Question.Answer[]
|
||||
}
|
||||
|
||||
const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")
|
||||
export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>(
|
||||
"question",
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
|
||||
return {
|
||||
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
||||
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
|
||||
metadata: {
|
||||
answers,
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
|
||||
const answers = await question
|
||||
.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
questions: params.questions,
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
.pipe(Effect.runPromise)
|
||||
|
||||
const formatted = params.questions
|
||||
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
|
||||
.join(", ")
|
||||
|
||||
return {
|
||||
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
||||
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
|
||||
metadata: {
|
||||
answers,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
} satisfies Tool.Def<typeof parameters, Metadata>
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -33,6 +33,8 @@ import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Env } from "../env"
|
||||
import { Question } from "../question"
|
||||
import { Todo } from "../session/todo"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
@@ -42,8 +44,11 @@ export namespace ToolRegistry {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (tool: Tool.Info) => Effect.Effect<void>
|
||||
readonly ids: () => Effect.Effect<string[]>
|
||||
readonly named: {
|
||||
task: Tool.Info
|
||||
read: Tool.Info
|
||||
}
|
||||
readonly tools: (
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
agent?: Agent.Info,
|
||||
@@ -52,156 +57,177 @@ export namespace ToolRegistry {
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
export const layer: Layer.Layer<Service, never, Config.Service | Plugin.Service | Question.Service | Todo.Service> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
const custom: Tool.Info[] = []
|
||||
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
|
||||
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
||||
return {
|
||||
id,
|
||||
init: async (initCtx) => ({
|
||||
parameters: z.object(def.args),
|
||||
description: def.description,
|
||||
execute: async (args, toolCtx) => {
|
||||
const pluginCtx = {
|
||||
...toolCtx,
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
} as unknown as PluginToolContext
|
||||
const result = await def.execute(args as any, pluginCtx)
|
||||
const out = await Truncate.output(result, {}, initCtx?.agent)
|
||||
return {
|
||||
title: "",
|
||||
output: out.truncated ? out.content : result,
|
||||
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
||||
}
|
||||
},
|
||||
}),
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
const custom: Tool.Info[] = []
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
||||
return {
|
||||
id,
|
||||
init: async (initCtx) => ({
|
||||
parameters: z.object(def.args),
|
||||
description: def.description,
|
||||
execute: async (args, toolCtx) => {
|
||||
const pluginCtx = {
|
||||
...toolCtx,
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
} as unknown as PluginToolContext
|
||||
const result = await def.execute(args as any, pluginCtx)
|
||||
const out = await Truncate.output(result, {}, initCtx?.agent)
|
||||
return {
|
||||
title: "",
|
||||
output: out.truncated ? out.content : result,
|
||||
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dirs = yield* config.directories()
|
||||
const matches = dirs.flatMap((dir) =>
|
||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||
)
|
||||
if (matches.length) yield* config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = yield* Effect.promise(
|
||||
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
|
||||
const dirs = yield* config.directories()
|
||||
const matches = dirs.flatMap((dir) =>
|
||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||
)
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||
if (matches.length) yield* config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = yield* Effect.promise(
|
||||
() => import(process.platform === "win32" ? match : pathToFileURL(match).href),
|
||||
)
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = yield* plugin.list()
|
||||
for (const p of plugins) {
|
||||
for (const [id, def] of Object.entries(p.tool ?? {})) {
|
||||
custom.push(fromPlugin(id, def))
|
||||
const plugins = yield* plugin.list()
|
||||
for (const p of plugins) {
|
||||
for (const [id, def] of Object.entries(p.tool ?? {})) {
|
||||
custom.push(fromPlugin(id, def))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { custom }
|
||||
}),
|
||||
)
|
||||
|
||||
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
|
||||
const cfg = yield* config.get()
|
||||
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
return [
|
||||
InvalidTool,
|
||||
...(question ? [QuestionTool] : []),
|
||||
BashTool,
|
||||
ReadTool,
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
EditTool,
|
||||
WriteTool,
|
||||
TaskTool,
|
||||
WebFetchTool,
|
||||
TodoWriteTool,
|
||||
WebSearchTool,
|
||||
CodeSearchTool,
|
||||
SkillTool,
|
||||
ApplyPatchTool,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
||||
...custom,
|
||||
]
|
||||
})
|
||||
|
||||
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const idx = s.custom.findIndex((t) => t.id === tool.id)
|
||||
if (idx >= 0) {
|
||||
s.custom.splice(idx, 1, tool)
|
||||
return
|
||||
}
|
||||
s.custom.push(tool)
|
||||
})
|
||||
|
||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const tools = yield* all(s.custom)
|
||||
return tools.map((t) => t.id)
|
||||
})
|
||||
|
||||
const tools = Effect.fn("ToolRegistry.tools")(function* (
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const allTools = yield* all(s.custom)
|
||||
const filtered = allTools.filter((tool) => {
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
const usePatch =
|
||||
!!Env.get("OPENCODE_E2E_LLM_URL") ||
|
||||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
|
||||
if (tool.id === "apply_patch") return usePatch
|
||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||
|
||||
return true
|
||||
})
|
||||
return yield* Effect.forEach(
|
||||
filtered,
|
||||
Effect.fnUntraced(function* (tool: Tool.Info) {
|
||||
using _ = log.time(tool.id)
|
||||
const next = yield* Effect.promise(() => tool.init({ agent }))
|
||||
const output = {
|
||||
description: next.description,
|
||||
parameters: next.parameters,
|
||||
}
|
||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
return {
|
||||
id: tool.id,
|
||||
description: output.description,
|
||||
parameters: output.parameters,
|
||||
execute: next.execute,
|
||||
formatValidationError: next.formatValidationError,
|
||||
}
|
||||
return { custom }
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ register, ids, tools })
|
||||
}),
|
||||
)
|
||||
const invalid = yield* build(InvalidTool)
|
||||
const ask = yield* build(QuestionTool)
|
||||
const bash = yield* build(BashTool)
|
||||
const read = yield* build(ReadTool)
|
||||
const glob = yield* build(GlobTool)
|
||||
const grep = yield* build(GrepTool)
|
||||
const edit = yield* build(EditTool)
|
||||
const write = yield* build(WriteTool)
|
||||
const task = yield* build(TaskTool)
|
||||
const fetch = yield* build(WebFetchTool)
|
||||
const todo = yield* build(TodoWriteTool)
|
||||
const search = yield* build(WebSearchTool)
|
||||
const code = yield* build(CodeSearchTool)
|
||||
const skill = yield* build(SkillTool)
|
||||
const patch = yield* build(ApplyPatchTool)
|
||||
const lsp = yield* build(LspTool)
|
||||
const batch = yield* build(BatchTool)
|
||||
const plan = yield* build(PlanExitTool)
|
||||
|
||||
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
|
||||
const cfg = yield* config.get()
|
||||
const question =
|
||||
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
return [
|
||||
invalid,
|
||||
...(question ? [ask] : []),
|
||||
bash,
|
||||
read,
|
||||
glob,
|
||||
grep,
|
||||
edit,
|
||||
write,
|
||||
task,
|
||||
fetch,
|
||||
todo,
|
||||
search,
|
||||
code,
|
||||
skill,
|
||||
patch,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
|
||||
...(cfg.experimental?.batch_tool === true ? [batch] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
|
||||
...custom,
|
||||
]
|
||||
})
|
||||
|
||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const tools = yield* all(s.custom)
|
||||
return tools.map((t) => t.id)
|
||||
})
|
||||
|
||||
const tools = Effect.fn("ToolRegistry.tools")(function* (
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const allTools = yield* all(s.custom)
|
||||
const filtered = allTools.filter((tool) => {
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
const usePatch =
|
||||
!!Env.get("OPENCODE_E2E_LLM_URL") ||
|
||||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
|
||||
if (tool.id === "apply_patch") return usePatch
|
||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||
|
||||
return true
|
||||
})
|
||||
return yield* Effect.forEach(
|
||||
filtered,
|
||||
Effect.fnUntraced(function* (tool: Tool.Info) {
|
||||
using _ = log.time(tool.id)
|
||||
const next = yield* Effect.promise(() => tool.init({ agent }))
|
||||
const output = {
|
||||
description: next.description,
|
||||
parameters: next.parameters,
|
||||
}
|
||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
return {
|
||||
id: tool.id,
|
||||
description: output.description,
|
||||
parameters: output.parameters,
|
||||
execute: next.execute,
|
||||
formatValidationError: next.formatValidationError,
|
||||
}
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ ids, named: { task, read }, tools })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() => layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer))),
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Question.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -1,31 +1,48 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION_WRITE from "./todowrite.txt"
|
||||
import { Todo } from "../session/todo"
|
||||
|
||||
export const TodoWriteTool = Tool.define("todowrite", {
|
||||
description: DESCRIPTION_WRITE,
|
||||
parameters: z.object({
|
||||
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
await ctx.ask({
|
||||
permission: "todowrite",
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
await Todo.update({
|
||||
sessionID: ctx.sessionID,
|
||||
todos: params.todos,
|
||||
})
|
||||
return {
|
||||
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
output: JSON.stringify(params.todos, null, 2),
|
||||
metadata: {
|
||||
todos: params.todos,
|
||||
},
|
||||
}
|
||||
},
|
||||
const parameters = z.object({
|
||||
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
todos: Todo.Info[]
|
||||
}
|
||||
|
||||
export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>(
|
||||
"todowrite",
|
||||
Effect.gen(function* () {
|
||||
const todo = yield* Todo.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION_WRITE,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
|
||||
await ctx.ask({
|
||||
permission: "todowrite",
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
await todo
|
||||
.update({
|
||||
sessionID: ctx.sessionID,
|
||||
todos: params.todos,
|
||||
})
|
||||
.pipe(Effect.runPromise)
|
||||
|
||||
return {
|
||||
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
output: JSON.stringify(params.todos, null, 2),
|
||||
metadata: {
|
||||
todos: params.todos,
|
||||
},
|
||||
}
|
||||
},
|
||||
} satisfies Tool.Def<typeof parameters, Metadata>
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import type { Permission } from "../permission"
|
||||
@@ -45,48 +46,67 @@ export namespace Tool {
|
||||
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
|
||||
}
|
||||
|
||||
export type InferParameters<T extends Info> = T extends Info<infer P> ? z.infer<P> : never
|
||||
export type InferMetadata<T extends Info> = T extends Info<any, infer M> ? M : never
|
||||
export type InferParameters<T> =
|
||||
T extends Info<infer P, any>
|
||||
? z.infer<P>
|
||||
: T extends Effect.Effect<Info<infer P, any>, any, any>
|
||||
? z.infer<P>
|
||||
: never
|
||||
export type InferMetadata<T> =
|
||||
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
|
||||
|
||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
id: string,
|
||||
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
|
||||
) {
|
||||
return async (initCtx?: InitContext) => {
|
||||
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = async (args, ctx) => {
|
||||
try {
|
||||
toolInfo.parameters.parse(args)
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||
throw new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
}
|
||||
throw new Error(
|
||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const result = await execute(args, ctx)
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
metadata: {
|
||||
...result.metadata,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
},
|
||||
}
|
||||
}
|
||||
return toolInfo
|
||||
}
|
||||
}
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
id: string,
|
||||
init: Info<Parameters, Result>["init"] | Def<Parameters, Result>,
|
||||
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
|
||||
): Info<Parameters, Result> {
|
||||
return {
|
||||
id,
|
||||
init: async (initCtx) => {
|
||||
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = async (args, ctx) => {
|
||||
try {
|
||||
toolInfo.parameters.parse(args)
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||
throw new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
}
|
||||
throw new Error(
|
||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const result = await execute(args, ctx)
|
||||
// skip truncation for tools that handle it themselves
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
metadata: {
|
||||
...result.metadata,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
},
|
||||
}
|
||||
}
|
||||
return toolInfo
|
||||
},
|
||||
init: wrap(id, init),
|
||||
}
|
||||
}
|
||||
|
||||
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
|
||||
id: string,
|
||||
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
|
||||
): Effect.Effect<Info<Parameters, Result>, never, R> {
|
||||
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Process } from "./process"
|
||||
|
||||
export interface GitResult {
|
||||
exitCode: number
|
||||
text(): string
|
||||
stdout: Buffer
|
||||
stderr: Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a git command.
|
||||
*
|
||||
* Uses Process helpers with stdin ignored to avoid protocol pipe inheritance
|
||||
* issues in embedded/client environments.
|
||||
*/
|
||||
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
|
||||
return Process.run(["git", ...args], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
stdin: "ignore",
|
||||
nothrow: true,
|
||||
})
|
||||
.then((result) => ({
|
||||
exitCode: result.code,
|
||||
text: () => result.stdout.toString(),
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}))
|
||||
.catch((error) => ({
|
||||
exitCode: 1,
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(error instanceof Error ? error.message : String(error)),
|
||||
}))
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Slug } from "@opencode-ai/util/slug"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
@@ -515,56 +516,24 @@ export namespace Worktree {
|
||||
|
||||
const worktreePath = entry.path
|
||||
|
||||
const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
|
||||
if (remoteList.code !== 0) {
|
||||
throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
|
||||
}
|
||||
|
||||
const remotes = remoteList.text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
const remote = remotes.includes("origin")
|
||||
? "origin"
|
||||
: remotes.length === 1
|
||||
? remotes[0]
|
||||
: remotes.includes("upstream")
|
||||
? "upstream"
|
||||
: ""
|
||||
|
||||
const remoteHead = remote
|
||||
? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
|
||||
: { code: 1, text: "", stderr: "" }
|
||||
|
||||
const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
|
||||
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
|
||||
const remoteBranch =
|
||||
remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
|
||||
|
||||
const [mainCheck, masterCheck] = yield* Effect.all(
|
||||
[
|
||||
git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
|
||||
git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
|
||||
|
||||
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||
if (!target) {
|
||||
const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree))
|
||||
if (!base) {
|
||||
throw new ResetFailedError({ message: "Default branch not found" })
|
||||
}
|
||||
|
||||
if (remoteBranch) {
|
||||
const sep = base.ref.indexOf("/")
|
||||
if (base.ref !== base.name && sep > 0) {
|
||||
const remote = base.ref.slice(0, sep)
|
||||
const branch = base.ref.slice(sep + 1)
|
||||
yield* gitExpect(
|
||||
["fetch", remote, remoteBranch],
|
||||
["fetch", remote, branch],
|
||||
{ cwd: Instance.worktree },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
|
||||
)
|
||||
}
|
||||
|
||||
yield* gitExpect(
|
||||
["reset", "--hard", target],
|
||||
["reset", "--hard", base.ref],
|
||||
{ cwd: worktreePath },
|
||||
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
|
||||
)
|
||||
|
||||
128
packages/opencode/test/git/git.test.ts
Normal file
128
packages/opencode/test/git/git.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { ManagedRuntime } from "effect"
|
||||
import { Git } from "../../src/git"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
|
||||
|
||||
async function withGit<T>(body: (rt: ManagedRuntime.ManagedRuntime<Git.Service, never>) => Promise<T>) {
|
||||
const rt = ManagedRuntime.make(Git.defaultLayer)
|
||||
try {
|
||||
return await body(rt)
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
describe("Git", () => {
|
||||
test("branch() returns current branch name", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
||||
expect(branch).toBeDefined()
|
||||
expect(typeof branch).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
test("branch() returns undefined for non-git directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
||||
expect(branch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("branch() returns undefined for detached HEAD", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const hash = (await $`git rev-parse HEAD`.cwd(tmp.path).quiet().text()).trim()
|
||||
await $`git checkout --detach ${hash}`.cwd(tmp.path).quiet()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.branch(tmp.path)))
|
||||
expect(branch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("defaultBranch() uses init.defaultBranch when available", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M trunk`.cwd(tmp.path).quiet()
|
||||
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const branch = await rt.runPromise(Git.Service.use((git) => git.defaultBranch(tmp.path)))
|
||||
expect(branch?.name).toBe("trunk")
|
||||
expect(branch?.ref).toBe("trunk")
|
||||
})
|
||||
})
|
||||
|
||||
test("status() handles special filenames", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const status = await rt.runPromise(Git.Service.use((git) => git.status(tmp.path)))
|
||||
expect(status).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: weird,
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("diff(), stats(), and mergeBase() parse tracked changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8")
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const [base, diff, stats] = await Promise.all([
|
||||
rt.runPromise(Git.Service.use((git) => git.mergeBase(tmp.path, "main"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.diff(tmp.path, "HEAD"))),
|
||||
rt.runPromise(Git.Service.use((git) => git.stats(tmp.path, "HEAD"))),
|
||||
])
|
||||
|
||||
expect(base).toBeTruthy()
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: weird,
|
||||
status: "modified",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(stats).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: weird,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("show() returns empty text for binary blobs", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
|
||||
|
||||
await withGit(async (rt) => {
|
||||
const text = await rt.runPromise(Git.Service.use((git) => git.show(tmp.path, "HEAD", "bin.dat")))
|
||||
expect(text).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,13 @@ import { Instance } from "../../src/project/instance"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { Vcs } from "../../src/project/vcs"
|
||||
|
||||
// Skip in CI — native @parcel/watcher binding needed
|
||||
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function withVcs(directory: string, body: () => Promise<void>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
@@ -22,8 +27,20 @@ async function withVcs(directory: string, body: () => Promise<void>) {
|
||||
})
|
||||
}
|
||||
|
||||
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
||||
function withVcsOnly(directory: string, body: () => Promise<void>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
Vcs.init()
|
||||
await body()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
||||
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
|
||||
|
||||
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
|
||||
function nextBranchUpdate(directory: string, timeout = 10_000) {
|
||||
return new Promise<string | undefined>((resolve, reject) => {
|
||||
let settled = false
|
||||
@@ -49,6 +66,10 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeVcs("Vcs", () => {
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
@@ -82,11 +103,7 @@ describeVcs("Vcs", () => {
|
||||
const pending = nextBranchUpdate(tmp.path)
|
||||
|
||||
const head = path.join(tmp.path, ".git", "HEAD")
|
||||
await fs.writeFile(
|
||||
head,
|
||||
`ref: refs/heads/${branch}
|
||||
`,
|
||||
)
|
||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||
|
||||
const updated = await pending
|
||||
expect(updated).toBe(branch)
|
||||
@@ -102,11 +119,7 @@ describeVcs("Vcs", () => {
|
||||
const pending = nextBranchUpdate(tmp.path)
|
||||
|
||||
const head = path.join(tmp.path, ".git", "HEAD")
|
||||
await fs.writeFile(
|
||||
head,
|
||||
`ref: refs/heads/${branch}
|
||||
`,
|
||||
)
|
||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||
|
||||
await pending
|
||||
const current = await Vcs.branch()
|
||||
@@ -114,3 +127,102 @@ describeVcs("Vcs", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Vcs diff", () => {
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("defaultBranch() falls back to main", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const branch = await Vcs.defaultBranch()
|
||||
expect(branch).toBe("main")
|
||||
})
|
||||
})
|
||||
|
||||
test("defaultBranch() uses init.defaultBranch when available", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M trunk`.cwd(tmp.path).quiet()
|
||||
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const branch = await Vcs.defaultBranch()
|
||||
expect(branch).toBe("trunk")
|
||||
})
|
||||
})
|
||||
|
||||
test("detects current branch from the active worktree", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await using wt = await tmpdir()
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
const dir = path.join(wt.path, "feature")
|
||||
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(dir, async () => {
|
||||
const [branch, base] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
||||
expect(branch).toBe("feature/test")
|
||||
expect(base).toBe("main")
|
||||
})
|
||||
})
|
||||
|
||||
test("diff('git') returns uncommitted changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await Vcs.diff("git")
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "file.txt",
|
||||
status: "modified",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("diff('git') handles special filenames", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await Vcs.diff("git")
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: weird,
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("diff('branch') returns changes against default branch", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await Vcs.diff("branch")
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "branch.txt",
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Question } from "../../src/question"
|
||||
import { Todo } from "../../src/session/todo"
|
||||
import { Session } from "../../src/session"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
@@ -160,7 +162,13 @@ function makeHttp() {
|
||||
AppFileSystem.defaultLayer,
|
||||
status,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
|
||||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
)
|
||||
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
||||
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||
|
||||
@@ -38,6 +38,8 @@ import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||
import { Question } from "../../src/question"
|
||||
import { Todo } from "../../src/session/todo"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import { SessionProcessor } from "../../src/session/processor"
|
||||
@@ -124,7 +126,13 @@ function makeHttp() {
|
||||
AppFileSystem.defaultLayer,
|
||||
status,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
|
||||
const question = Question.layer.pipe(Layer.provideMerge(deps))
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
)
|
||||
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
||||
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
|
||||
import { z } from "zod"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
import { QuestionTool } from "../../src/tool/question"
|
||||
import * as QuestionModule from "../../src/question"
|
||||
import { Question } from "../../src/question"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test-session"),
|
||||
@@ -15,55 +19,69 @@ const ctx = {
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
|
||||
for (;;) {
|
||||
const items = yield* question.list()
|
||||
const item = items[0]
|
||||
if (item) return item
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
})
|
||||
|
||||
describe("tool.question", () => {
|
||||
let askSpy: any
|
||||
it.live("should successfully execute with valid question parameters", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
const toolInfo = yield* QuestionTool
|
||||
const tool = yield* Effect.promise(() => toolInfo.init())
|
||||
const questions = [
|
||||
{
|
||||
question: "What is your favorite color?",
|
||||
header: "Color",
|
||||
options: [
|
||||
{ label: "Red", description: "The color of passion" },
|
||||
{ label: "Blue", description: "The color of sky" },
|
||||
],
|
||||
multiple: false,
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => {
|
||||
return []
|
||||
})
|
||||
})
|
||||
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
|
||||
|
||||
afterEach(() => {
|
||||
askSpy.mockRestore()
|
||||
})
|
||||
const result = yield* Fiber.join(fiber)
|
||||
expect(result.title).toBe("Asked 1 question")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("should successfully execute with valid question parameters", async () => {
|
||||
const tool = await QuestionTool.init()
|
||||
const questions = [
|
||||
{
|
||||
question: "What is your favorite color?",
|
||||
header: "Color",
|
||||
options: [
|
||||
{ label: "Red", description: "The color of passion" },
|
||||
{ label: "Blue", description: "The color of sky" },
|
||||
],
|
||||
multiple: false,
|
||||
},
|
||||
]
|
||||
it.live("should now pass with a header longer than 12 but less than 30 chars", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
const toolInfo = yield* QuestionTool
|
||||
const tool = yield* Effect.promise(() => toolInfo.init())
|
||||
const questions = [
|
||||
{
|
||||
question: "What is your favorite animal?",
|
||||
header: "This Header is Over 12",
|
||||
options: [{ label: "Dog", description: "Man's best friend" }],
|
||||
},
|
||||
]
|
||||
|
||||
askSpy.mockResolvedValueOnce([["Red"]])
|
||||
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
|
||||
|
||||
const result = await tool.execute({ questions }, ctx)
|
||||
expect(askSpy).toHaveBeenCalledTimes(1)
|
||||
expect(result.title).toBe("Asked 1 question")
|
||||
})
|
||||
|
||||
test("should now pass with a header longer than 12 but less than 30 chars", async () => {
|
||||
const tool = await QuestionTool.init()
|
||||
const questions = [
|
||||
{
|
||||
question: "What is your favorite animal?",
|
||||
header: "This Header is Over 12",
|
||||
options: [{ label: "Dog", description: "Man's best friend" }],
|
||||
},
|
||||
]
|
||||
|
||||
askSpy.mockResolvedValueOnce([["Dog"]])
|
||||
|
||||
const result = await tool.execute({ questions }, ctx)
|
||||
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
|
||||
})
|
||||
const result = yield* Fiber.join(fiber)
|
||||
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough
|
||||
// test("should throw an Error for header exceeding 30 characters", async () => {
|
||||
|
||||
@@ -27,45 +27,37 @@ describe("Tool.define", () => {
|
||||
await tool.init()
|
||||
await tool.init()
|
||||
|
||||
// The original object's execute should never be overwritten
|
||||
expect(original.execute).toBe(originalExecute)
|
||||
})
|
||||
|
||||
test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
|
||||
let executeCalls = 0
|
||||
let calls = 0
|
||||
|
||||
const tool = Tool.define(
|
||||
"test-tool",
|
||||
makeTool("test", () => executeCalls++),
|
||||
makeTool("test", () => calls++),
|
||||
)
|
||||
|
||||
// Call init() many times to simulate many agentic steps
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await tool.init()
|
||||
}
|
||||
|
||||
// Resolve the tool and call execute
|
||||
const resolved = await tool.init()
|
||||
executeCalls = 0
|
||||
calls = 0
|
||||
|
||||
// Capture the stack trace inside execute to measure wrapper depth
|
||||
let stackInsideExecute = ""
|
||||
const origExec = resolved.execute
|
||||
let stack = ""
|
||||
const exec = resolved.execute
|
||||
resolved.execute = async (args: any, ctx: any) => {
|
||||
const result = await origExec.call(resolved, args, ctx)
|
||||
const err = new Error()
|
||||
stackInsideExecute = err.stack || ""
|
||||
const result = await exec.call(resolved, args, ctx)
|
||||
stack = new Error().stack || ""
|
||||
return result
|
||||
}
|
||||
|
||||
await resolved.execute(defaultArgs, {} as any)
|
||||
expect(executeCalls).toBe(1)
|
||||
expect(calls).toBe(1)
|
||||
|
||||
// Count how many times tool.ts appears in the stack.
|
||||
// With the fix: 1 wrapper layer (from the most recent init()).
|
||||
// Without the fix: 101 wrapper layers from accumulated closures.
|
||||
const toolTsFrames = stackInsideExecute.split("\n").filter((l) => l.includes("tool.ts")).length
|
||||
expect(toolTsFrames).toBeLessThan(5)
|
||||
const frames = stack.split("\n").filter((l) => l.includes("tool.ts")).length
|
||||
expect(frames).toBeLessThan(5)
|
||||
})
|
||||
|
||||
test("function-defined tool returns fresh objects and is unaffected", async () => {
|
||||
@@ -74,7 +66,6 @@ describe("Tool.define", () => {
|
||||
const first = await tool.init()
|
||||
const second = await tool.init()
|
||||
|
||||
// Function-defined tools return distinct objects each time
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
@@ -84,7 +75,6 @@ describe("Tool.define", () => {
|
||||
const first = await tool.init()
|
||||
const second = await tool.init()
|
||||
|
||||
// Each init() should return a separate object so wrappers don't accumulate
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ import type {
|
||||
TuiSelectSessionResponses,
|
||||
TuiShowToastResponses,
|
||||
TuiSubmitPromptResponses,
|
||||
VcsDiffResponses,
|
||||
VcsGetResponses,
|
||||
WorktreeCreateErrors,
|
||||
WorktreeCreateInput,
|
||||
@@ -3848,6 +3849,38 @@ export class Vcs extends HeyApiClient {
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VCS diff
|
||||
*
|
||||
* Retrieve the current git diff for the working tree or against the default branch.
|
||||
*/
|
||||
public diff<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
mode: "git" | "branch"
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "query", key: "mode" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<VcsDiffResponses, unknown, ThrowOnError>({
|
||||
url: "/vcs/diff",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Command extends HeyApiClient {
|
||||
|
||||
@@ -2003,6 +2003,7 @@ export type Path = {
|
||||
|
||||
export type VcsInfo = {
|
||||
branch?: string
|
||||
default_branch?: string
|
||||
}
|
||||
|
||||
export type Command = {
|
||||
@@ -5065,6 +5066,26 @@ export type VcsGetResponses = {
|
||||
|
||||
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
|
||||
|
||||
export type VcsDiffData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
mode: "git" | "branch"
|
||||
}
|
||||
url: "/vcs/diff"
|
||||
}
|
||||
|
||||
export type VcsDiffResponses = {
|
||||
/**
|
||||
* VCS diff
|
||||
*/
|
||||
200: Array<FileDiff>
|
||||
}
|
||||
|
||||
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]
|
||||
|
||||
export type CommandListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -6806,6 +6806,59 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/vcs/diff": {
|
||||
"get": {
|
||||
"operationId": "vcs.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "workspace",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mode",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["git", "branch"]
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"summary": "Get VCS diff",
|
||||
"description": "Retrieve the current git diff for the working tree or against the default branch.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "VCS diff",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/command": {
|
||||
"get": {
|
||||
"operationId": "command.list",
|
||||
@@ -12504,6 +12557,9 @@
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"default_branch": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -359,11 +359,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<Show when={hasDiffs()} fallback={props.empty}>
|
||||
<div class="pb-6">
|
||||
<Accordion multiple value={open()} onChange={handleChange}>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
<For each={props.diffs}>
|
||||
{(diff) => {
|
||||
let wrapper: HTMLDivElement | undefined
|
||||
|
||||
const item = createMemo(() => diffs().get(file)!)
|
||||
const file = diff.file
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||
@@ -372,9 +371,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
|
||||
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
||||
const afterText = () => (typeof item().after === "string" ? item().after : "")
|
||||
const changedLines = () => item().additions + item().deletions
|
||||
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||
const changedLines = () => diff.additions + diff.deletions
|
||||
const mediaKind = createMemo(() => mediaKindFromPath(file))
|
||||
|
||||
const tooLarge = createMemo(() => {
|
||||
@@ -385,9 +384,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
})
|
||||
|
||||
const isAdded = () =>
|
||||
item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||
diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
||||
const isDeleted = () =>
|
||||
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
|
||||
const selectedLines = createMemo(() => {
|
||||
const current = selection()
|
||||
@@ -425,7 +424,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
file,
|
||||
selection,
|
||||
comment,
|
||||
preview: selectionPreview(item(), selection),
|
||||
preview: selectionPreview(diff, selection),
|
||||
})
|
||||
},
|
||||
onUpdate: ({ id, comment, selection }) => {
|
||||
@@ -434,7 +433,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
file,
|
||||
selection,
|
||||
comment,
|
||||
preview: selectionPreview(item(), selection),
|
||||
preview: selectionPreview(diff, selection),
|
||||
})
|
||||
},
|
||||
onDelete: (comment) => {
|
||||
@@ -513,7 +512,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{i18n.t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
<DiffChanges changes={item()} />
|
||||
<DiffChanges changes={diff} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={isDeleted()}>
|
||||
@@ -527,7 +526,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={item()} />
|
||||
<DiffChanges changes={diff} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<span data-slot="session-review-diff-chevron">
|
||||
@@ -582,7 +581,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
preloadedDiff={item().preloaded}
|
||||
preloadedDiff={diff.preloaded}
|
||||
diffStyle={diffStyle()}
|
||||
onRendered={() => {
|
||||
props.onDiffRendered?.()
|
||||
@@ -599,17 +598,17 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
commentedLines={commentedLines()}
|
||||
before={{
|
||||
name: file,
|
||||
contents: typeof item().before === "string" ? item().before : "",
|
||||
contents: typeof diff.before === "string" ? diff.before : "",
|
||||
}}
|
||||
after={{
|
||||
name: file,
|
||||
contents: typeof item().after === "string" ? item().after : "",
|
||||
contents: typeof diff.after === "string" ? diff.after : "",
|
||||
}}
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: file,
|
||||
before: item().before,
|
||||
after: item().after,
|
||||
before: diff.before,
|
||||
after: diff.after,
|
||||
readFile: props.readFile,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -92,33 +92,15 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs"]
|
||||
> [data-component="collapsible"]
|
||||
> [data-slot="collapsible-trigger"][aria-expanded="true"] {
|
||||
position: sticky;
|
||||
top: var(--sticky-accordion-top, 0px);
|
||||
z-index: 20;
|
||||
height: 40px;
|
||||
padding-bottom: 8px;
|
||||
background-color: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-component="session-turn-diffs-trigger"] {
|
||||
width: 100%;
|
||||
[data-slot="session-turn-diffs-header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-title"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-label"] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
@@ -126,28 +108,38 @@
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-count"] {
|
||||
color: var(--text-base);
|
||||
[data-slot="session-turn-diffs-toggle"] {
|
||||
color: var(--text-interactive-base);
|
||||
font-family: var(--font-family-sans);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-x-large);
|
||||
line-height: var(--line-height-large);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-meta"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
[data-component="session-turn-diffs-group"]:hover [data-slot="session-turn-diffs-toggle"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
margin-left: -6px;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
[data-component="session-turn-diffs-group"][data-show-all] [data-slot="session-turn-diffs-toggle"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-component="diff-changes"][data-variant="bars"] {
|
||||
transform: translateY(1px);
|
||||
[data-slot="session-turn-diffs-more"] {
|
||||
color: var(--text-weak);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
margin-top: 12px;
|
||||
padding: 0 0 6px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-link-base);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions
|
||||
import { Card } from "./card"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Icon } from "./icon"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
@@ -241,23 +240,20 @@ export function SessionTurn(
|
||||
}, [])
|
||||
.reverse()
|
||||
})
|
||||
const MAX_FILES = 10
|
||||
const edited = createMemo(() => diffs().length)
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
showAll: false,
|
||||
expanded: [] as string[],
|
||||
})
|
||||
const open = () => state.open
|
||||
const showAll = () => state.showAll
|
||||
const expanded = () => state.expanded
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
open,
|
||||
(value, prev) => {
|
||||
if (!value && prev) setState("expanded", [])
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const overflow = createMemo(() => Math.max(0, edited() - MAX_FILES))
|
||||
const visible = createMemo(() => (showAll() ? diffs() : diffs().slice(0, MAX_FILES)))
|
||||
const toggleAll = () => {
|
||||
autoScroll.pause()
|
||||
setState("showAll", !showAll())
|
||||
}
|
||||
|
||||
const assistantMessages = createMemo(
|
||||
() => {
|
||||
@@ -425,101 +421,100 @@ export function SessionTurn(
|
||||
</Show>
|
||||
<SessionRetry status={status()} show={active()} />
|
||||
<Show when={edited() > 0 && !working()}>
|
||||
<div data-slot="session-turn-diffs">
|
||||
<Collapsible open={open()} onOpenChange={(value) => setState("open", value)} variant="ghost">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="session-turn-diffs-trigger">
|
||||
<div data-slot="session-turn-diffs-title">
|
||||
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
|
||||
<span data-slot="session-turn-diffs-count">
|
||||
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<div data-slot="session-turn-diffs-meta">
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Show when={open()}>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "40px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) =>
|
||||
setState("expanded", Array.isArray(value) ? value : value ? [value] : [])
|
||||
}
|
||||
>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
<div
|
||||
data-slot="session-turn-diffs"
|
||||
data-component="session-turn-diffs-group"
|
||||
data-show-all={showAll() || undefined}
|
||||
>
|
||||
<div data-slot="session-turn-diffs-header">
|
||||
<span data-slot="session-turn-diffs-label">
|
||||
{edited()} {i18n.t("ui.sessionTurn.diffs.changed")}{" "}
|
||||
{i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<DiffChanges changes={diffs()} />
|
||||
<Show when={overflow() > 0}>
|
||||
<span data-slot="session-turn-diffs-toggle" onClick={toggleAll}>
|
||||
{showAll() ? i18n.t("ui.sessionTurn.diffs.showLess") : i18n.t("ui.sessionTurn.diffs.showAll")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "40px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={visible()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [shown, setShown] = createSignal(false)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setShown(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setShown(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">
|
||||
{`\u202A${getDirectory(diff.file)}\u202C`}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">
|
||||
{`\u202A${getDirectory(diff.file)}\u202C`}
|
||||
</span>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={shown()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
<Show when={!showAll() && overflow() > 0}>
|
||||
<div data-slot="session-turn-diffs-more" onClick={toggleAll}>
|
||||
{i18n.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const dict: Record<string, string> = {
|
||||
"ui.sessionReview.title": "Session changes",
|
||||
"ui.sessionReview.title.git": "Git changes",
|
||||
"ui.sessionReview.title.branch": "Branch changes",
|
||||
"ui.sessionReview.title.lastTurn": "Last turn changes",
|
||||
"ui.sessionReview.diffStyle.unified": "Unified",
|
||||
"ui.sessionReview.diffStyle.split": "Split",
|
||||
@@ -38,6 +40,10 @@ export const dict: Record<string, string> = {
|
||||
"ui.sessionTurn.steps.hide": "Hide steps",
|
||||
"ui.sessionTurn.summary.response": "Response",
|
||||
"ui.sessionTurn.diff.showMore": "Show more changes ({{count}})",
|
||||
"ui.sessionTurn.diffs.changed": "Changed",
|
||||
"ui.sessionTurn.diffs.showAll": "Show all",
|
||||
"ui.sessionTurn.diffs.showLess": "Show less",
|
||||
"ui.sessionTurn.diffs.more": "+{{count}} more files",
|
||||
|
||||
"ui.sessionTurn.retry.retrying": "retrying",
|
||||
"ui.sessionTurn.retry.inSeconds": "in {{seconds}}s",
|
||||
|
||||
10
turbo.json
10
turbo.json
@@ -13,9 +13,19 @@
|
||||
"outputs": [],
|
||||
"passThroughEnv": ["*"]
|
||||
},
|
||||
"opencode#test:ci": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".artifacts/unit/junit.xml"],
|
||||
"passThroughEnv": ["*"]
|
||||
},
|
||||
"@opencode-ai/app#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/app#test:ci": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".artifacts/unit/junit.xml"],
|
||||
"passThroughEnv": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user