mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-21 00:04:22 +00:00
Compare commits
13 Commits
github-v1.
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e48d7fe82 | ||
|
|
2a904ec56f | ||
|
|
0ce61c817b | ||
|
|
1ffed2fa6c | ||
|
|
c79f1a72d8 | ||
|
|
9c5bbba6ea | ||
|
|
ce17f9dd94 | ||
|
|
92ab4217c2 | ||
|
|
7867ba441f | ||
|
|
7419ebc872 | ||
|
|
7e681b0bc0 | ||
|
|
4e9ef3ecc1 | ||
|
|
7e0e35af3f |
35
.github/workflows/publish.yml
vendored
35
.github/workflows/publish.yml
vendored
@@ -41,6 +41,13 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Install OpenCode
|
||||
if: inputs.bump || inputs.version
|
||||
run: bun i -g opencode-ai
|
||||
@@ -49,14 +56,16 @@ jobs:
|
||||
run: |
|
||||
./script/version.ts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
OPENCODE_BUMP: ${{ inputs.bump }}
|
||||
OPENCODE_VERSION: ${{ inputs.version }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GH_REPO: ${{ (github.ref_name == 'beta' && 'anomalyco/opencode-beta') || github.repository }}
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
release: ${{ steps.version.outputs.release }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
repo: ${{ steps.version.outputs.repo }}
|
||||
|
||||
build-cli:
|
||||
needs: version
|
||||
@@ -69,6 +78,13 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
run: |
|
||||
@@ -76,7 +92,8 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -189,6 +206,13 @@ jobs:
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
run: cargo tauri --version
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build and upload artifacts
|
||||
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
|
||||
timeout-minutes: 60
|
||||
@@ -196,14 +220,16 @@ jobs:
|
||||
projectPath: packages/desktop
|
||||
uploadWorkflowArtifacts: true
|
||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
||||
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
|
||||
updaterJsonPreferNsis: true
|
||||
releaseId: ${{ needs.version.outputs.release }}
|
||||
tagName: ${{ needs.version.outputs.tag }}
|
||||
releaseDraft: true
|
||||
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
||||
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
|
||||
releaseCommitish: ${{ github.sha }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -280,4 +306,5 @@ jobs:
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
NPM_CONFIG_PROVENANCE: false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
description: Translate content for a specified locale while preserving technical terms
|
||||
mode: subagent
|
||||
model: opencode/gemini-3-pro
|
||||
model: opencode/gemini-3.1-pro
|
||||
---
|
||||
|
||||
You are a professional translator and localization specialist.
|
||||
|
||||
@@ -6,6 +6,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
await gotoSession()
|
||||
|
||||
const terminals = page.locator(terminalSelector)
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
const opened = await terminals.first().isVisible()
|
||||
|
||||
if (!opened) {
|
||||
@@ -21,6 +22,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.press("Control+Alt+T")
|
||||
|
||||
await expect(terminals).toHaveCount(2)
|
||||
await expect(terminals.nth(1).locator("textarea")).toHaveCount(1)
|
||||
await expect(tabs).toHaveCount(2)
|
||||
await expect(terminals).toHaveCount(1)
|
||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -89,6 +89,8 @@ const EXAMPLES = [
|
||||
"prompt.example.25",
|
||||
] as const
|
||||
|
||||
const NON_EMPTY_TEXT = /[^\s\u200B]/
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
@@ -636,7 +638,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
let buffer = ""
|
||||
|
||||
const flushText = () => {
|
||||
const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
|
||||
let content = buffer
|
||||
if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n")
|
||||
if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
|
||||
buffer = ""
|
||||
if (!content) return
|
||||
parts.push({ type: "text", content, start: position, end: position + content.length })
|
||||
@@ -714,10 +718,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const rawParts = parseFromDOM()
|
||||
const images = imageAttachments()
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
||||
const trimmed = rawText.replace(/\u200B/g, "").trim()
|
||||
const rawText =
|
||||
rawParts.length === 1 && rawParts[0]?.type === "text"
|
||||
? rawParts[0].content
|
||||
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
||||
const hasNonText = rawParts.some((part) => part.type !== "text")
|
||||
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
||||
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0
|
||||
|
||||
if (shouldReset) {
|
||||
closePopover()
|
||||
@@ -757,19 +763,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const addPart = (part: ContentPart) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return
|
||||
if (part.type === "image") return false
|
||||
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
const currentPrompt = prompt.current()
|
||||
const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
|
||||
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return false
|
||||
|
||||
if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) {
|
||||
editorRef.focus()
|
||||
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||
setCursorPosition(editorRef, cursor)
|
||||
}
|
||||
|
||||
if (selection.rangeCount === 0) return false
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!editorRef.contains(range.startContainer)) return false
|
||||
|
||||
if (part.type === "file" || part.type === "agent") {
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
const rawText = prompt
|
||||
.current()
|
||||
.map((p) => ("content" in p ? p.content : ""))
|
||||
.join("")
|
||||
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||
const pill = createPill(part)
|
||||
const gap = document.createTextNode(" ")
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (atMatch) {
|
||||
const start = atMatch.index ?? cursorPosition - atMatch[0].length
|
||||
@@ -784,8 +802,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} else if (part.type === "text") {
|
||||
const range = selection.getRangeAt(0)
|
||||
}
|
||||
|
||||
if (part.type === "text") {
|
||||
const fragment = createTextFragment(part.content)
|
||||
const last = fragment.lastChild
|
||||
range.deleteContents()
|
||||
@@ -821,6 +840,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
handleInput()
|
||||
closePopover()
|
||||
return true
|
||||
}
|
||||
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
|
||||
@@ -7,6 +7,19 @@ import { getCursorPosition } from "./editor-dom"
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||
const LARGE_PASTE_CHARS = 8000
|
||||
const LARGE_PASTE_BREAKS = 120
|
||||
|
||||
function largePaste(text: string) {
|
||||
if (text.length >= LARGE_PASTE_CHARS) return true
|
||||
let breaks = 0
|
||||
for (const char of text) {
|
||||
if (char !== "\n") continue
|
||||
breaks += 1
|
||||
if (breaks >= LARGE_PASTE_BREAKS) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
editor: () => HTMLDivElement | undefined
|
||||
@@ -14,7 +27,7 @@ type PromptAttachmentsInput = {
|
||||
isDialogActive: () => boolean
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
focusEditor: () => void
|
||||
addPart: (part: ContentPart) => void
|
||||
addPart: (part: ContentPart) => boolean
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
}
|
||||
|
||||
@@ -89,6 +102,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
}
|
||||
|
||||
if (!plainText) return
|
||||
|
||||
if (largePaste(plainText)) {
|
||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
||||
input.focusEditor()
|
||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
||||
}
|
||||
|
||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
||||
if (inserted) return
|
||||
|
||||
|
||||
@@ -24,6 +24,28 @@ describe("prompt-input editor dom", () => {
|
||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
})
|
||||
|
||||
test("createTextFragment avoids break-node explosion for large multiline content", () => {
|
||||
const content = Array.from({ length: 220 }, () => "line").join("\n")
|
||||
const fragment = createTextFragment(content)
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(fragment)
|
||||
|
||||
expect(container.childNodes.length).toBe(1)
|
||||
expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
|
||||
expect(container.textContent).toBe(content)
|
||||
})
|
||||
|
||||
test("createTextFragment keeps terminal break in large multiline fallback", () => {
|
||||
const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
|
||||
const fragment = createTextFragment(content)
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(fragment)
|
||||
|
||||
expect(container.childNodes.length).toBe(2)
|
||||
expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1))
|
||||
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
})
|
||||
|
||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||
const container = document.createElement("div")
|
||||
container.appendChild(document.createTextNode("ab\u200B"))
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
const MAX_BREAKS = 200
|
||||
|
||||
export function createTextFragment(content: string): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment()
|
||||
let breaks = 0
|
||||
for (const char of content) {
|
||||
if (char !== "\n") continue
|
||||
breaks += 1
|
||||
if (breaks > MAX_BREAKS) {
|
||||
const tail = content.endsWith("\n")
|
||||
const text = tail ? content.slice(0, -1) : content
|
||||
if (text) fragment.appendChild(document.createTextNode(text))
|
||||
if (tail) fragment.appendChild(document.createElement("br"))
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
const segments = content.split("\n")
|
||||
segments.forEach((segment, index) => {
|
||||
if (segment) {
|
||||
|
||||
@@ -250,6 +250,18 @@ export const SettingsGeneral: Component = () => {
|
||||
)}
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||
>
|
||||
<div data-action="settings-reasoning-summaries">
|
||||
<Switch
|
||||
checked={settings.general.showReasoningSummaries()}
|
||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -540,7 +540,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
disposed = true
|
||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
|
||||
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
|
||||
|
||||
const finalize = () => {
|
||||
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Settings {
|
||||
general: {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
showReasoningSummaries: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -42,6 +43,7 @@ const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
showReasoningSummaries: false,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
@@ -120,6 +122,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setReleaseNotes(value: boolean) {
|
||||
setStore("general", "releaseNotes", value)
|
||||
},
|
||||
showReasoningSummaries: withFallback(
|
||||
() => store.general?.showReasoningSummaries,
|
||||
defaultSettings.general.showReasoningSummaries,
|
||||
),
|
||||
setShowReasoningSummaries(value: boolean) {
|
||||
setStore("general", "showReasoningSummaries", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
|
||||
@@ -610,6 +610,8 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
|
||||
"settings.general.row.wayland.title": "Use native Wayland",
|
||||
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
|
||||
|
||||
@@ -943,15 +943,12 @@ export default function Page() {
|
||||
if (next === dockHeight) return
|
||||
|
||||
const el = scroller
|
||||
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
|
||||
const delta = next - dockHeight
|
||||
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
|
||||
|
||||
dockHeight = next
|
||||
|
||||
if (stick && el) {
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
|
||||
})
|
||||
}
|
||||
if (stick) autoScroll.forceScrollToBottom()
|
||||
|
||||
if (el) scheduleScrollState(el)
|
||||
scrollSpy.markDirty()
|
||||
|
||||
@@ -14,6 +14,7 @@ import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
@@ -80,6 +81,7 @@ export function MessageTimeline(props: {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -535,6 +537,7 @@ export function MessageTimeline(props: {
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
|
||||
@@ -67,11 +67,11 @@ export function TerminalPanel() {
|
||||
on(
|
||||
() => terminal.active(),
|
||||
(activeId) => {
|
||||
if (!activeId || !opened()) return
|
||||
if (!activeId || !open()) return
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
focusTerminalById(activeId)
|
||||
setTimeout(() => focusTerminalById(activeId), 0)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -209,21 +209,17 @@ export function TerminalPanel() {
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<For each={all()}>
|
||||
{(pty) => (
|
||||
<div
|
||||
id={`terminal-wrapper-${pty.id}`}
|
||||
class="absolute inset-0"
|
||||
style={{
|
||||
display: terminal.active() === pty.id ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<Show when={pty.id} keyed>
|
||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={terminal.active()} keyed>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APIEvent } from "@solidjs/start"
|
||||
import { DownloadPlatform } from "./types"
|
||||
import type { APIEvent } from "@solidjs/start"
|
||||
import type { DownloadPlatform } from "../types"
|
||||
|
||||
const assetNames: Record<string, string> = {
|
||||
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
|
||||
@@ -17,17 +17,20 @@ const downloadNames: Record<string, string> = {
|
||||
"windows-x64-nsis": "OpenCode Desktop Installer.exe",
|
||||
} satisfies { [K in DownloadPlatform]?: string }
|
||||
|
||||
export async function GET({ params: { platform } }: APIEvent) {
|
||||
export async function GET({ params: { platform, channel } }: APIEvent) {
|
||||
const assetName = assetNames[platform]
|
||||
if (!assetName) return new Response("Not Found", { status: 404 })
|
||||
|
||||
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
|
||||
cf: {
|
||||
// in case gh releases has rate limits
|
||||
cacheTtl: 60 * 5,
|
||||
cacheEverything: true,
|
||||
},
|
||||
} as any)
|
||||
const resp = await fetch(
|
||||
`https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`,
|
||||
{
|
||||
cf: {
|
||||
// in case gh releases has rate limits
|
||||
cacheTtl: 60 * 5,
|
||||
cacheEverything: true,
|
||||
},
|
||||
} as any,
|
||||
)
|
||||
|
||||
const downloadName = downloadNames[platform]
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import "./index.css"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
import { A, createAsync, query } from "@solidjs/router"
|
||||
import { Header } from "~/component/header"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { IconCopy, IconCheck } from "~/component/icon"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { A } from "@solidjs/router"
|
||||
import { createSignal, type JSX, onMount, Show } from "solid-js"
|
||||
import { Faq } from "~/component/faq"
|
||||
import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { Header } from "~/component/header"
|
||||
import { IconCheck, IconCopy } from "~/component/icon"
|
||||
import { Legal } from "~/component/legal"
|
||||
import { LocaleLinks } from "~/component/locale-links"
|
||||
import { config } from "~/config"
|
||||
import { createSignal, onMount, Show, JSX } from "solid-js"
|
||||
import { DownloadPlatform } from "./types"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { useLanguage } from "~/context/language"
|
||||
import { LocaleLinks } from "~/component/locale-links"
|
||||
import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png"
|
||||
import type { DownloadPlatform } from "./types"
|
||||
|
||||
type OS = "macOS" | "Windows" | "Linux" | null
|
||||
|
||||
@@ -40,8 +40,8 @@ function getDownloadPlatform(os: OS): DownloadPlatform {
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadHref(platform: DownloadPlatform) {
|
||||
return `/download/${platform}`
|
||||
function getDownloadHref(platform: DownloadPlatform, channel: "stable" | "beta" = "stable") {
|
||||
return `/download/${channel}/${platform}`
|
||||
}
|
||||
|
||||
function IconDownload(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
|
||||
@@ -107,11 +107,14 @@ export async function handler(
|
||||
const startTimestamp = Date.now()
|
||||
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
|
||||
const reqBody = JSON.stringify(
|
||||
providerInfo.modifyBody({
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
...(providerInfo.payloadModifier ?? {}),
|
||||
}),
|
||||
providerInfo.modifyBody(
|
||||
{
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
...(providerInfo.payloadModifier ?? {}),
|
||||
},
|
||||
authInfo?.workspaceID,
|
||||
),
|
||||
)
|
||||
logger.debug("REQUEST URL: " + reqUrl)
|
||||
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
|
||||
|
||||
@@ -18,9 +18,10 @@ export const openaiHelper: ProviderHelper = () => ({
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
modifyBody: (body: Record<string, any>) => {
|
||||
return body
|
||||
},
|
||||
modifyBody: (body: Record<string, any>, workspaceID?: string) => ({
|
||||
...body,
|
||||
...(workspaceID ? { safety_identifier: workspaceID } : {}),
|
||||
}),
|
||||
createBinaryStreamDecoder: () => undefined,
|
||||
streamSeparator: "\n\n",
|
||||
createUsageParser: () => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
|
||||
format: ZenData.Format
|
||||
modifyUrl: (providerApi: string, isStream?: boolean) => string
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||
modifyBody: (body: Record<string, any>) => Record<string, any>
|
||||
modifyBody: (body: Record<string, any>, workspaceID?: string) => Record<string, any>
|
||||
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
|
||||
streamSeparator: string
|
||||
createUsageParser: () => {
|
||||
|
||||
@@ -320,7 +320,7 @@ pub fn spawn_command(
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args(["-l", "-c", &line]);
|
||||
cmd.args(["-il", "-c", &line]);
|
||||
|
||||
for (key, value) in envs {
|
||||
cmd.env(key, value);
|
||||
|
||||
21
packages/desktop/src-tauri/tauri.beta.conf.json
Normal file
21
packages/desktop/src-tauri/tauri.beta.conf.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenCode Beta",
|
||||
"identifier": "ai.opencode.desktop.beta",
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true,
|
||||
"linux": {
|
||||
"rpm": {
|
||||
"compression": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK",
|
||||
"endpoints": ["https://github.com/anomalyco/opencode-beta/releases/latest/download/latest.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
@@ -12,8 +12,9 @@ const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import pkg from "../package.json"
|
||||
|
||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
// Fetch and generate models.dev snapshot
|
||||
const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
@@ -26,7 +27,11 @@ await Bun.write(
|
||||
console.log("Generated models-snapshot.ts")
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (await fs.promises.readdir(path.join(dir, "migration"), { withFileTypes: true }))
|
||||
const migrationDirs = (
|
||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
)
|
||||
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
@@ -171,7 +176,6 @@ for (const item of targets) {
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
//@ts-ignore (bun types aren't up to date)
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
target: name.replace(pkg.name, "bun") as any,
|
||||
@@ -214,7 +218,7 @@ if (Script.release) {
|
||||
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
|
||||
}
|
||||
}
|
||||
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber`
|
||||
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`
|
||||
}
|
||||
|
||||
export { binaries }
|
||||
|
||||
@@ -41,13 +41,38 @@ export namespace Pty {
|
||||
|
||||
const token = (ws: Socket) => {
|
||||
const data = ws.data
|
||||
if (!data || typeof data !== "object") return
|
||||
if (data === undefined) return
|
||||
if (data === null) return
|
||||
if (typeof data !== "object") return data
|
||||
|
||||
const events = (data as { events?: unknown }).events
|
||||
if (events && typeof events === "object") return events
|
||||
const id = (data as { connId?: unknown }).connId
|
||||
if (typeof id === "number" || typeof id === "string") return id
|
||||
|
||||
const href = (data as { href?: unknown }).href
|
||||
if (typeof href === "string") return href
|
||||
|
||||
const url = (data as { url?: unknown }).url
|
||||
if (url && typeof url === "object") return url
|
||||
if (typeof url === "string") return url
|
||||
if (url && typeof url === "object") {
|
||||
const href = (url as { href?: unknown }).href
|
||||
if (typeof href === "string") return href
|
||||
return url
|
||||
}
|
||||
|
||||
const events = (data as { events?: unknown }).events
|
||||
if (typeof events === "number" || typeof events === "string") return events
|
||||
if (events && typeof events === "object") {
|
||||
const id = (events as { connId?: unknown }).connId
|
||||
if (typeof id === "number" || typeof id === "string") return id
|
||||
|
||||
const id2 = (events as { connection?: unknown }).connection
|
||||
if (typeof id2 === "number" || typeof id2 === "string") return id2
|
||||
|
||||
const id3 = (events as { id?: unknown }).id
|
||||
if (typeof id3 === "number" || typeof id3 === "string") return id3
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -210,7 +235,7 @@ export namespace Pty {
|
||||
continue
|
||||
}
|
||||
|
||||
if (sub.token !== undefined && token(ws) !== sub.token) {
|
||||
if (token(ws) !== sub.token) {
|
||||
session.subscribers.delete(ws)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Worktree } from "../../worktree"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Project } from "../../project/project"
|
||||
import { MCP } from "../../mcp"
|
||||
import { Session } from "../../session"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/session",
|
||||
describeRoute({
|
||||
summary: "List sessions",
|
||||
description:
|
||||
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
operationId: "experimental.session.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.GlobalInfo.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||
cursor: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
|
||||
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const limit = query.limit ?? 100
|
||||
const sessions: Session.GlobalInfo[] = []
|
||||
for await (const session of Session.listGlobal({
|
||||
directory: query.directory,
|
||||
roots: query.roots,
|
||||
start: query.start,
|
||||
cursor: query.cursor,
|
||||
search: query.search,
|
||||
limit: limit + 1,
|
||||
archived: query.archived,
|
||||
})) {
|
||||
sessions.push(session)
|
||||
}
|
||||
const hasMore = sessions.length > limit
|
||||
const list = hasMore ? sessions.slice(0, limit) : sessions
|
||||
if (hasMore && list.length > 0) {
|
||||
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
|
||||
}
|
||||
return c.json(list)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/resource",
|
||||
describeRoute({
|
||||
|
||||
@@ -10,8 +10,10 @@ import { Flag } from "../flag/flag"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import type { SQL } from "../storage/db"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
@@ -154,6 +156,24 @@ export namespace Session {
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const ProjectInfo = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
worktree: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProjectSummary",
|
||||
})
|
||||
export type ProjectInfo = z.output<typeof ProjectInfo>
|
||||
|
||||
export const GlobalInfo = Info.extend({
|
||||
project: ProjectInfo.nullable(),
|
||||
}).meta({
|
||||
ref: "GlobalSession",
|
||||
})
|
||||
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||
|
||||
export const Event = {
|
||||
Created: BusEvent.define(
|
||||
"session.created",
|
||||
@@ -544,6 +564,75 @@ export namespace Session {
|
||||
}
|
||||
}
|
||||
|
||||
export function* listGlobal(input?: {
|
||||
directory?: string
|
||||
roots?: boolean
|
||||
start?: number
|
||||
cursor?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
archived?: boolean
|
||||
}) {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
if (input?.directory) {
|
||||
conditions.push(eq(SessionTable.directory, input.directory))
|
||||
}
|
||||
if (input?.roots) {
|
||||
conditions.push(isNull(SessionTable.parent_id))
|
||||
}
|
||||
if (input?.start) {
|
||||
conditions.push(gte(SessionTable.time_updated, input.start))
|
||||
}
|
||||
if (input?.cursor) {
|
||||
conditions.push(lt(SessionTable.time_updated, input.cursor))
|
||||
}
|
||||
if (input?.search) {
|
||||
conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
}
|
||||
if (!input?.archived) {
|
||||
conditions.push(isNull(SessionTable.time_archived))
|
||||
}
|
||||
|
||||
const limit = input?.limit ?? 100
|
||||
|
||||
const rows = Database.use((db) => {
|
||||
const query =
|
||||
conditions.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(and(...conditions))
|
||||
: db.select().from(SessionTable)
|
||||
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
|
||||
})
|
||||
|
||||
const ids = [...new Set(rows.map((row) => row.project_id))]
|
||||
const projects = new Map<string, ProjectInfo>()
|
||||
|
||||
if (ids.length > 0) {
|
||||
const items = Database.use((db) =>
|
||||
db
|
||||
.select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(inArray(ProjectTable.id, ids))
|
||||
.all(),
|
||||
)
|
||||
for (const item of items) {
|
||||
projects.set(item.id, {
|
||||
id: item.id,
|
||||
name: item.name ?? undefined,
|
||||
worktree: item.worktree,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const project = projects.get(row.project_id) ?? null
|
||||
yield { ...fromRow(row), project }
|
||||
}
|
||||
}
|
||||
|
||||
export const children = fn(Identifier.schema("session"), async (parentID) => {
|
||||
const project = Instance.project
|
||||
const rows = Database.use((db) =>
|
||||
|
||||
@@ -97,4 +97,48 @@ describe("pty", () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not leak output when socket data mutates in-place", async () => {
|
||||
await using dir = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: async () => {
|
||||
const a = await Pty.create({ command: "cat", title: "a" })
|
||||
try {
|
||||
const outA: string[] = []
|
||||
const outB: string[] = []
|
||||
|
||||
const ctx = { connId: 1 }
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
data: ctx,
|
||||
send: (data: unknown) => {
|
||||
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
},
|
||||
close: () => {
|
||||
// no-op
|
||||
},
|
||||
}
|
||||
|
||||
Pty.connect(a.id, ws as any)
|
||||
outA.length = 0
|
||||
|
||||
// Simulate the runtime mutating per-connection data without
|
||||
// swapping the reference (ws.data stays the same object).
|
||||
ctx.connId = 2
|
||||
ws.send = (data: unknown) => {
|
||||
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
}
|
||||
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await Bun.sleep(100)
|
||||
|
||||
expect(outB.join("")).not.toContain("AAA")
|
||||
} finally {
|
||||
await Pty.remove(a.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
89
packages/opencode/test/server/global-session-list.test.ts
Normal file
89
packages/opencode/test/server/global-session-list.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
describe("Session.listGlobal", () => {
|
||||
test("lists sessions across projects with project metadata", async () => {
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
|
||||
const firstSession = await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => Session.create({ title: "first-session" }),
|
||||
})
|
||||
const secondSession = await Instance.provide({
|
||||
directory: second.path,
|
||||
fn: async () => Session.create({ title: "second-session" }),
|
||||
})
|
||||
|
||||
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(firstSession.id)
|
||||
expect(ids).toContain(secondSession.id)
|
||||
|
||||
const firstProject = Project.get(firstSession.projectID)
|
||||
const secondProject = Project.get(secondSession.projectID)
|
||||
|
||||
const firstItem = sessions.find((session) => session.id === firstSession.id)
|
||||
const secondItem = sessions.find((session) => session.id === secondSession.id)
|
||||
|
||||
expect(firstItem?.project?.id).toBe(firstProject?.id)
|
||||
expect(firstItem?.project?.worktree).toBe(firstProject?.worktree)
|
||||
expect(secondItem?.project?.id).toBe(secondProject?.id)
|
||||
expect(secondItem?.project?.worktree).toBe(secondProject?.worktree)
|
||||
})
|
||||
|
||||
test("excludes archived sessions by default", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const archived = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "archived-session" }),
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
|
||||
})
|
||||
|
||||
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).not.toContain(archived.id)
|
||||
|
||||
const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
|
||||
const allIds = allSessions.map((session) => session.id)
|
||||
|
||||
expect(allIds).toContain(archived.id)
|
||||
})
|
||||
|
||||
test("supports cursor pagination", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const first = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "page-one" }),
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const second = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "page-two" }),
|
||||
})
|
||||
|
||||
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
|
||||
expect(page.length).toBe(1)
|
||||
expect(page[0].id).toBe(second.id)
|
||||
|
||||
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
|
||||
const ids = next.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(first.id)
|
||||
expect(ids).not.toContain(second.id)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
EventTuiSessionSelect,
|
||||
EventTuiToastShow,
|
||||
ExperimentalResourceListResponses,
|
||||
ExperimentalSessionListResponses,
|
||||
FileListResponses,
|
||||
FilePartInput,
|
||||
FilePartSource,
|
||||
@@ -898,6 +899,48 @@ export class Worktree extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Session extends HeyApiClient {
|
||||
/**
|
||||
* List sessions
|
||||
*
|
||||
* Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
roots?: boolean
|
||||
start?: number
|
||||
cursor?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
archived?: boolean
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "roots" },
|
||||
{ in: "query", key: "start" },
|
||||
{ in: "query", key: "cursor" },
|
||||
{ in: "query", key: "search" },
|
||||
{ in: "query", key: "limit" },
|
||||
{ in: "query", key: "archived" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<ExperimentalSessionListResponses, unknown, ThrowOnError>({
|
||||
url: "/experimental/session",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Resource extends HeyApiClient {
|
||||
/**
|
||||
* Get MCP resources
|
||||
@@ -920,13 +963,18 @@ export class Resource extends HeyApiClient {
|
||||
}
|
||||
|
||||
export class Experimental extends HeyApiClient {
|
||||
private _session?: Session
|
||||
get session(): Session {
|
||||
return (this._session ??= new Session({ client: this.client }))
|
||||
}
|
||||
|
||||
private _resource?: Resource
|
||||
get resource(): Resource {
|
||||
return (this._resource ??= new Resource({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Session extends HeyApiClient {
|
||||
export class Session2 extends HeyApiClient {
|
||||
/**
|
||||
* List sessions
|
||||
*
|
||||
@@ -3231,9 +3279,9 @@ export class OpencodeClient extends HeyApiClient {
|
||||
return (this._experimental ??= new Experimental({ client: this.client }))
|
||||
}
|
||||
|
||||
private _session?: Session
|
||||
get session(): Session {
|
||||
return (this._session ??= new Session({ client: this.client }))
|
||||
private _session?: Session2
|
||||
get session(): Session2 {
|
||||
return (this._session ??= new Session2({ client: this.client }))
|
||||
}
|
||||
|
||||
private _part?: Part
|
||||
|
||||
@@ -2044,6 +2044,45 @@ export type WorktreeResetInput = {
|
||||
directory: string
|
||||
}
|
||||
|
||||
export type ProjectSummary = {
|
||||
id: string
|
||||
name?: string
|
||||
worktree: string
|
||||
}
|
||||
|
||||
export type GlobalSession = {
|
||||
id: string
|
||||
slug: string
|
||||
projectID: string
|
||||
directory: string
|
||||
parentID?: string
|
||||
summary?: {
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
diffs?: Array<FileDiff>
|
||||
}
|
||||
share?: {
|
||||
url: string
|
||||
}
|
||||
title: string
|
||||
version: string
|
||||
time: {
|
||||
created: number
|
||||
updated: number
|
||||
compacting?: number
|
||||
archived?: number
|
||||
}
|
||||
permission?: PermissionRuleset
|
||||
revert?: {
|
||||
messageID: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
project: ProjectSummary | null
|
||||
}
|
||||
|
||||
export type McpResource = {
|
||||
name: string
|
||||
uri: string
|
||||
@@ -2870,6 +2909,51 @@ export type WorktreeResetResponses = {
|
||||
|
||||
export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses]
|
||||
|
||||
export type ExperimentalSessionListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
/**
|
||||
* Filter sessions by project directory
|
||||
*/
|
||||
directory?: string
|
||||
/**
|
||||
* Only return root sessions (no parentID)
|
||||
*/
|
||||
roots?: boolean
|
||||
/**
|
||||
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
|
||||
*/
|
||||
start?: number
|
||||
/**
|
||||
* Return sessions updated before this timestamp (milliseconds since epoch)
|
||||
*/
|
||||
cursor?: number
|
||||
/**
|
||||
* Filter sessions by title (case-insensitive)
|
||||
*/
|
||||
search?: string
|
||||
/**
|
||||
* Maximum number of sessions to return
|
||||
*/
|
||||
limit?: number
|
||||
/**
|
||||
* Include archived sessions (default false)
|
||||
*/
|
||||
archived?: boolean
|
||||
}
|
||||
url: "/experimental/session"
|
||||
}
|
||||
|
||||
export type ExperimentalSessionListResponses = {
|
||||
/**
|
||||
* List of sessions
|
||||
*/
|
||||
200: Array<GlobalSession>
|
||||
}
|
||||
|
||||
export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses]
|
||||
|
||||
export type ExperimentalResourceListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -1202,6 +1202,92 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/session": {
|
||||
"get": {
|
||||
"operationId": "experimental.session.list",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter sessions by project directory"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "roots",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": "Only return root sessions (no parentID)"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "start",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "cursor",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "Return sessions updated before this timestamp (milliseconds since epoch)"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "search",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter sessions by title (case-insensitive)"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
},
|
||||
"description": "Maximum number of sessions to return"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "archived",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": "Include archived sessions (default false)"
|
||||
}
|
||||
],
|
||||
"summary": "List sessions",
|
||||
"description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of sessions",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GlobalSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.session.list({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/resource": {
|
||||
"get": {
|
||||
"operationId": "experimental.resource.list",
|
||||
@@ -10499,6 +10585,129 @@
|
||||
},
|
||||
"required": ["directory"]
|
||||
},
|
||||
"ProjectSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"worktree": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "worktree"]
|
||||
},
|
||||
"GlobalSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"slug": {
|
||||
"type": "string"
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"parentID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"summary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"additions": {
|
||||
"type": "number"
|
||||
},
|
||||
"deletions": {
|
||||
"type": "number"
|
||||
},
|
||||
"files": {
|
||||
"type": "number"
|
||||
},
|
||||
"diffs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["additions", "deletions", "files"]
|
||||
},
|
||||
"share": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"updated": {
|
||||
"type": "number"
|
||||
},
|
||||
"compacting": {
|
||||
"type": "number"
|
||||
},
|
||||
"archived": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created", "updated"]
|
||||
},
|
||||
"permission": {
|
||||
"$ref": "#/components/schemas/PermissionRuleset"
|
||||
},
|
||||
"revert": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messageID": {
|
||||
"type": "string"
|
||||
},
|
||||
"partID": {
|
||||
"type": "string"
|
||||
},
|
||||
"snapshot": {
|
||||
"type": "string"
|
||||
},
|
||||
"diff": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["messageID"]
|
||||
},
|
||||
"project": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProjectSummary"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"]
|
||||
},
|
||||
"McpResource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface MessageProps {
|
||||
parts: PartType[]
|
||||
showAssistantCopyPartID?: string | null
|
||||
interrupted?: boolean
|
||||
showReasoningSummaries?: boolean
|
||||
}
|
||||
|
||||
export interface MessagePartProps {
|
||||
@@ -264,14 +265,14 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function renderable(part: PartType) {
|
||||
function renderable(part: PartType, showReasoningSummaries = true) {
|
||||
if (part.type === "tool") {
|
||||
if (HIDDEN_TOOLS.has(part.tool)) return false
|
||||
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
|
||||
return true
|
||||
}
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return showReasoningSummaries && !!part.text?.trim()
|
||||
return !!PART_MAPPING[part.type]
|
||||
}
|
||||
|
||||
@@ -280,6 +281,7 @@ export function AssistantParts(props: {
|
||||
showAssistantCopyPartID?: string | null
|
||||
turnDurationMs?: number
|
||||
working?: boolean
|
||||
showReasoningSummaries?: boolean
|
||||
}) {
|
||||
const data = useData()
|
||||
const emptyParts: PartType[] = []
|
||||
@@ -300,7 +302,7 @@ export function AssistantParts(props: {
|
||||
|
||||
const parts = props.messages.flatMap((message) =>
|
||||
list(data.store.part?.[message.id], emptyParts)
|
||||
.filter(renderable)
|
||||
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
|
||||
.map((part) => ({ message, part })),
|
||||
)
|
||||
|
||||
@@ -480,6 +482,7 @@ export function Message(props: MessageProps) {
|
||||
message={assistantMessage() as AssistantMessage}
|
||||
parts={props.parts}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
showReasoningSummaries={props.showReasoningSummaries}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
@@ -491,6 +494,7 @@ export function AssistantMessageDisplay(props: {
|
||||
message: AssistantMessage
|
||||
parts: PartType[]
|
||||
showAssistantCopyPartID?: string | null
|
||||
showReasoningSummaries?: boolean
|
||||
}) {
|
||||
const grouped = createMemo(() => {
|
||||
const keys: string[] = []
|
||||
@@ -519,7 +523,7 @@ export function AssistantMessageDisplay(props: {
|
||||
}
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
if (!renderable(part)) return
|
||||
if (!renderable(part, props.showReasoningSummaries ?? true)) return
|
||||
|
||||
if (isContextGroupTool(part)) {
|
||||
if (start < 0) start = index
|
||||
@@ -1603,6 +1607,12 @@ ToolRegistry.register({
|
||||
const i18n = useI18n()
|
||||
const diffComponent = useDiffComponent()
|
||||
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
|
||||
const pending = createMemo(() => props.status === "pending" || props.status === "running")
|
||||
const single = createMemo(() => {
|
||||
const list = files()
|
||||
if (list.length !== 1) return
|
||||
return list[0]
|
||||
})
|
||||
const [expanded, setExpanded] = createSignal<string[]>([])
|
||||
let seeded = false
|
||||
|
||||
@@ -1621,100 +1631,147 @@ ToolRegistry.register({
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="apply-patch-tool">
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
defer
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.patch"),
|
||||
subtitle: subtitle(),
|
||||
}}
|
||||
>
|
||||
<Show when={files().length > 0}>
|
||||
<Accordion
|
||||
multiple
|
||||
data-scope="apply-patch"
|
||||
style={{ "--sticky-accordion-offset": "40px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
<Show
|
||||
when={single()}
|
||||
fallback={
|
||||
<div data-component="apply-patch-tool">
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
defer
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.patch"),
|
||||
subtitle: subtitle(),
|
||||
}}
|
||||
>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const active = createMemo(() => expanded().includes(file.filePath))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
<Show when={files().length > 0}>
|
||||
<Accordion
|
||||
multiple
|
||||
data-scope="apply-patch"
|
||||
style={{ "--sticky-accordion-offset": "40px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const active = createMemo(() => expanded().includes(file.filePath))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
})
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Accordion.Item value={file.filePath} data-type={file.type}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file.relativePath, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file.relativePath.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span>
|
||||
return (
|
||||
<Accordion.Item value={file.filePath} data-type={file.type}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file.relativePath, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file.relativePath.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file.type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file.type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file.type === "move"}>
|
||||
<span data-slot="apply-patch-change" data-type="modified">
|
||||
{i18n.t("ui.patch.action.moved")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file.type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file.type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file.type === "move"}>
|
||||
<span data-slot="apply-patch-change" data-type="modified">
|
||||
{i18n.t("ui.patch.action.moved")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{ name: file.filePath, contents: file.before }}
|
||||
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{ name: file.filePath, contents: file.before }}
|
||||
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
defer
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<Show when={pending()} fallback={i18n.t("ui.tool.patch")}>
|
||||
<TextShimmer text={i18n.t("ui.tool.patch")} />
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="message-part-title-filename">{getFilename(file().relativePath)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && file().relativePath.includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<span data-slot="message-part-directory">{getDirectory(file().relativePath)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">
|
||||
<Show when={!pending()}>
|
||||
<DiffChanges changes={{ additions: file().additions, deletions: file().deletions }} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-component="edit-content">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{ name: file().filePath, contents: file().before }}
|
||||
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
|
||||
/>
|
||||
</div>
|
||||
</BasicTool>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: var(--text-weak);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
@@ -52,6 +54,16 @@
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-thinking-heading"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-weaker);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.error-card {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { AssistantParts, Message } from "./message-part"
|
||||
import { AssistantParts, Message, PART_MAPPING } from "./message-part"
|
||||
import { Card } from "./card"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
@@ -83,15 +83,55 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
|
||||
const hidden = new Set(["todowrite", "todoread"])
|
||||
|
||||
function visible(part: PartType) {
|
||||
function partState(part: PartType, showReasoningSummaries: boolean) {
|
||||
if (part.type === "tool") {
|
||||
if (hidden.has(part.tool)) return false
|
||||
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
|
||||
return true
|
||||
if (hidden.has(part.tool)) return
|
||||
if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
|
||||
return "visible" as const
|
||||
}
|
||||
if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
|
||||
if (part.type === "reasoning") {
|
||||
if (showReasoningSummaries) return "visible" as const
|
||||
return
|
||||
}
|
||||
if (PART_MAPPING[part.type]) return "visible" as const
|
||||
return
|
||||
}
|
||||
|
||||
function clean(value: string) {
|
||||
return value
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
|
||||
.replace(/[*_~]+/g, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function heading(text: string) {
|
||||
const markdown = text.replace(/\r\n?/g, "\n")
|
||||
|
||||
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
|
||||
if (html?.[1]) {
|
||||
const value = clean(html[1].replace(/<[^>]+>/g, " "))
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
|
||||
if (atx?.[1]) {
|
||||
const value = clean(atx[1])
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
|
||||
if (setext?.[1]) {
|
||||
const value = clean(setext[1])
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
|
||||
if (strong?.[1]) {
|
||||
const value = clean(strong[1])
|
||||
if (value) return value
|
||||
}
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
return false
|
||||
}
|
||||
|
||||
export function SessionTurn(
|
||||
@@ -99,6 +139,7 @@ export function SessionTurn(
|
||||
sessionID: string
|
||||
messageID: string
|
||||
lastUserMessageID?: string
|
||||
showReasoningSummaries?: boolean
|
||||
onUserInteracted?: () => void
|
||||
classes?: {
|
||||
root?: string
|
||||
@@ -242,6 +283,7 @@ export function SessionTurn(
|
||||
|
||||
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
|
||||
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
|
||||
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
|
||||
|
||||
const assistantCopyPartID = createMemo(() => {
|
||||
if (working()) return null
|
||||
@@ -265,9 +307,33 @@ export function SessionTurn(
|
||||
const assistantVisible = createMemo(() =>
|
||||
assistantMessages().reduce((count, message) => {
|
||||
const parts = list(data.store.part?.[message.id], emptyParts)
|
||||
return count + parts.filter(visible).length
|
||||
return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length
|
||||
}, 0),
|
||||
)
|
||||
const assistantTailVisible = createMemo(() =>
|
||||
assistantMessages()
|
||||
.flatMap((message) => list(data.store.part?.[message.id], emptyParts))
|
||||
.flatMap((part) => {
|
||||
if (partState(part, showReasoningSummaries()) !== "visible") return []
|
||||
if (part.type === "text") return ["text" as const]
|
||||
return ["other" as const]
|
||||
})
|
||||
.at(-1),
|
||||
)
|
||||
const reasoningHeading = createMemo(() =>
|
||||
assistantMessages()
|
||||
.flatMap((message) => list(data.store.part?.[message.id], emptyParts))
|
||||
.filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning")
|
||||
.map((part) => heading(part.text))
|
||||
.filter((text): text is string => !!text)
|
||||
.at(-1),
|
||||
)
|
||||
const showThinking = createMemo(() => {
|
||||
if (!working() || !!error()) return false
|
||||
if (showReasoningSummaries()) return assistantVisible() === 0
|
||||
if (assistantTailVisible() === "text") return false
|
||||
return true
|
||||
})
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
working,
|
||||
@@ -295,11 +361,6 @@ export function SessionTurn(
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message message={msg()} parts={parts()} interrupted={interrupted()} />
|
||||
</div>
|
||||
<Show when={working() && assistantVisible() === 0 && !error()}>
|
||||
<div data-slot="session-turn-thinking">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
|
||||
<AssistantParts
|
||||
@@ -307,9 +368,18 @@ export function SessionTurn(
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
turnDurationMs={turnDurationMs()}
|
||||
working={working()}
|
||||
showReasoningSummaries={showReasoningSummaries()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showThinking()}>
|
||||
<div data-slot="session-turn-thinking">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
<Show when={!showReasoningSummaries() && reasoningHeading()}>
|
||||
{(text) => <span data-slot="session-turn-thinking-heading">{text()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={edited() > 0 && !working()}>
|
||||
<div data-slot="session-turn-diffs">
|
||||
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
||||
|
||||
@@ -57,13 +57,16 @@ await $`bun install`
|
||||
await import(`../packages/sdk/js/script/build.ts`)
|
||||
|
||||
if (Script.release) {
|
||||
await $`git commit -am "release: v${Script.version}"`
|
||||
await $`git tag v${Script.version}`
|
||||
await $`git fetch origin`
|
||||
await $`git cherry-pick HEAD..origin/dev`.nothrow()
|
||||
await $`git push origin HEAD --tags --no-verify --force-with-lease`
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
await $`gh release edit v${Script.version} --draft=false`
|
||||
if (!Script.preview) {
|
||||
await $`git commit -am "release: v${Script.version}"`
|
||||
await $`git tag v${Script.version}`
|
||||
await $`git fetch origin`
|
||||
await $`git cherry-pick HEAD..origin/dev`.nothrow()
|
||||
await $`git push origin HEAD --tags --no-verify --force-with-lease`
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
}
|
||||
|
||||
await $`gh release edit v${Script.version} --draft=false --repo ${process.env.GH_REPO}`
|
||||
}
|
||||
|
||||
console.log("\n=== cli ===\n")
|
||||
|
||||
@@ -17,8 +17,16 @@ if (!Script.preview) {
|
||||
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
|
||||
output.push(`release=${release.databaseId}`)
|
||||
output.push(`tag=${release.tagName}`)
|
||||
} else if (Script.channel === "beta") {
|
||||
await $`gh release create v${Script.version} -d --title "v${Script.version}" --repo ${process.env.GH_REPO}`
|
||||
const release =
|
||||
await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json()
|
||||
output.push(`release=${release.databaseId}`)
|
||||
output.push(`tag=${release.tagName}`)
|
||||
}
|
||||
|
||||
output.push(`repo=${process.env.GH_REPO}`)
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
await Bun.write(process.env.GITHUB_OUTPUT, output.join("\n"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user