From 5a9f4e5c606ece93653fff11f460caefec5df303 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 4 Dec 2025 13:28:35 +0900 Subject: [PATCH 01/23] fix: ensure checkUpgrade sets `init:` (#5040) --- packages/opencode/src/cli/cmd/tui/worker.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 50274f44200..7754b4a3953 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -2,6 +2,7 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import { Log } from "@/util/log" import { Instance } from "@/project/instance" +import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" @@ -43,6 +44,7 @@ export const rpc = { async checkUpgrade(input: { directory: string }) { await Instance.provide({ directory: input.directory, + init: InstanceBootstrap, fn: async () => { await upgrade().catch(() => {}) }, From 2e63fedb767069cbec4cebe8df84d4867712e967 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 4 Dec 2025 04:29:03 +0000 Subject: [PATCH 02/23] chore: format code --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2d21e6302f3..da5e3450e3e 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c25c51bcef5..a811885e1dc 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 6b80fff2bb4a6c82c9a327162e96906c4067ae90 Mon Sep 17 00:00:00 2001 From: wsx99outlook <247713593+wsx99outlook@users.noreply.github.com> Date: Thu, 4 Dec 2025 04:30:00 +0000 Subject: [PATCH 03/23] ci: use blacksmith runners in review workflow too (#5042) --- .github/workflows/review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 32c7c7b1144..0739d57c030 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -14,7 +14,7 @@ jobs: (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')) - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read pull-requests: write From bcf740f98a6396bd8dbc475e08f1acffc5b0f4a0 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 3 Dec 2025 23:33:40 -0500 Subject: [PATCH 04/23] zen: make session provider sticky --- .../console/app/src/routes/zen/util/handler.ts | 14 +++++++++++++- .../src/routes/zen/util/stickyProviderTracker.ts | 16 ++++++++++++++++ packages/console/core/src/model.ts | 1 + 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 packages/console/app/src/routes/zen/util/stickyProviderTracker.ts diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 623c931277e..7844a3ab079 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -21,6 +21,7 @@ import { oaCompatHelper } from "./provider/openai-compatible" import { createRateLimiter } from "./rateLimiter" import { createDataDumper } from "./dataDumper" import { createTrialLimiter } from "./trialLimiter" +import { createStickyTracker } from "./stickyProviderTracker" type ZenData = Awaited> type RetryOptions = { @@ -68,9 +69,11 @@ export async function handler( const isTrial = await trialLimiter?.isTrial() const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip) await rateLimiter?.check() + const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId) + const stickyProvider = await stickyTracker?.get() const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { - const providerInfo = selectProvider(zenData, modelInfo, sessionId, isTrial ?? false, retry) + const providerInfo = selectProvider(zenData, modelInfo, sessionId, isTrial ?? false, retry, stickyProvider) const authInfo = await authenticate(modelInfo, providerInfo) validateBilling(authInfo, modelInfo) validateModelSettings(authInfo) @@ -121,6 +124,9 @@ export async function handler( dataDumper?.provideModel(providerInfo.storeModel) dataDumper?.provideRequest(reqBody) + // Store sticky provider + await stickyTracker?.set(providerInfo.id) + // Scrub response headers const resHeaders = new Headers() const keepHeaders = ["content-type", "cache-control"] @@ -289,12 +295,18 @@ export async function handler( sessionId: string, isTrial: boolean, retry: RetryOptions, + stickyProvider: string | undefined, ) { const provider = (() => { if (isTrial) { return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider) } + if (stickyProvider) { + const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider) + if (provider) return provider + } + if (retry.retryCount === MAX_RETRIES) { return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider) } diff --git a/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts b/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts new file mode 100644 index 00000000000..63cbb0a68c9 --- /dev/null +++ b/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts @@ -0,0 +1,16 @@ +import { Resource } from "@opencode-ai/console-resource" + +export function createStickyTracker(stickyProvider: boolean, session: string) { + if (!stickyProvider) return + if (!session) return + const key = `sticky:${session}` + + return { + get: async () => { + return await Resource.GatewayKv.get(key) + }, + set: async (providerId: string) => { + await Resource.GatewayKv.put(key, providerId, { expirationTtl: 86400 }) + }, + } +} diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 8cc181b7ca8..5a4a98fe94e 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -24,6 +24,7 @@ export namespace ZenData { cost: ModelCostSchema, cost200K: ModelCostSchema.optional(), allowAnonymous: z.boolean().optional(), + stickyProvider: z.boolean().optional(), trial: z .object({ limit: z.number(), From 088ebb967f514fa08ba10b21c46dc123ffdf3f84 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 4 Dec 2025 01:07:04 -0600 Subject: [PATCH 05/23] ci: only maintainer can trigger --- .github/workflows/review.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 0739d57c030..ffcf085d2cd 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -1,16 +1,12 @@ name: Guidelines Check on: - pull_request_target: - types: [opened, ready_for_review] issue_comment: types: [created] jobs: check-guidelines: if: | - (github.event_name == 'pull_request_target' && - github.event.pull_request.draft == false) || (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')) From 45bc7a6a9d2560650881be0e554a124b4f925ae5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 4 Dec 2025 01:14:09 -0600 Subject: [PATCH 06/23] ci: cleaner --- .github/workflows/review.yml | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index ffcf085d2cd..ac93ca94e7e 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -7,25 +7,14 @@ on: jobs: check-guidelines: if: | - (github.event_name == 'issue_comment' && - github.event.issue.pull_request && - startsWith(github.event.comment.body, '/review')) + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/review') && + contains(fromJson('["OWNER","MEMBER"]'), github.event.comment.author_association) runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read pull-requests: write steps: - - name: Check if user has write permission - if: github.event_name == 'issue_comment' - run: | - PERMISSION=$(gh api /repos/${{ github.repository }}/collaborators/${{ github.event.comment.user.login }}/permission --jq '.permission') - if [[ "$PERMISSION" != "write" && "$PERMISSION" != "admin" ]]; then - echo "User does not have write permission" - exit 1 - fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Get PR number id: pr-number run: | From efbb973393755143ee35a5a8d3414744d1ddb910 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 4 Dec 2025 12:04:38 +0000 Subject: [PATCH 07/23] ignore: update download stats 2025-12-04 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index 25678e915d5..72198597234 100644 --- a/STATS.md +++ b/STATS.md @@ -159,3 +159,4 @@ | 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) | | 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) | | 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) | +| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) | From 1d6e3d477b00df5c114903d30da31a3405f9ad5d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 4 Dec 2025 06:56:48 -0600 Subject: [PATCH 08/23] fix(tui): cursor color --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 7271e2fc69e..37603ffa759 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -269,7 +269,7 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (props.disabled) input.cursorColor = theme.backgroundElement - if (!props.disabled) input.cursorColor = theme.primary + if (!props.disabled) input.cursorColor = theme.text }) const [store, setStore] = createStore<{ @@ -805,12 +805,12 @@ export function Prompt(props: PromptProps) { ref={(r: TextareaRenderable) => { input = r setTimeout(() => { - input.cursorColor = highlight() + input.cursorColor = theme.text }, 0) }} onMouseDown={(r: MouseEvent) => r.target?.focus()} focusedBackgroundColor={theme.backgroundElement} - cursorColor={highlight()} + cursorColor={theme.text} syntaxStyle={syntax()} /> From 27c99b46cba5543bb0e7b9e8982140eebfa59041 Mon Sep 17 00:00:00 2001 From: Daniel Gray Date: Thu, 4 Dec 2025 11:12:58 -0600 Subject: [PATCH 09/23] Preserve prompt input when creating new session (#4993) --- packages/opencode/src/cli/cmd/tui/app.tsx | 10 ++++++++- .../cli/cmd/tui/component/prompt/index.tsx | 4 ++++ .../src/cli/cmd/tui/context/prompt.tsx | 18 +++++++++++++++ .../src/cli/cmd/tui/context/route.tsx | 2 ++ .../opencode/src/cli/cmd/tui/routes/home.tsx | 22 ++++++++++++++----- .../src/cli/cmd/tui/routes/session/index.tsx | 7 +++++- 6 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/prompt.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 30d7b5c6912..3fb20f16797 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -32,6 +32,7 @@ import { KVProvider, useKV } from "./context/kv" import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" +import { PromptRefProvider, usePromptRef } from "./context/prompt" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -119,7 +120,9 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise - + + + @@ -160,6 +163,7 @@ function App() { const { theme, mode, setMode } = useTheme() const sync = useSync() const exit = useExit() + const promptRef = usePromptRef() createEffect(() => { console.log(JSON.stringify(route.data)) @@ -225,8 +229,12 @@ function App() { keybind: "session_new", category: "Session", onSelect: () => { + const current = promptRef.current + // Don't require focus - if there's any text, preserve it + const currentPrompt = current?.current?.input ? current.current : undefined route.navigate({ type: "home", + initialPrompt: currentPrompt, }) dialog.clear() }, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 37603ffa759..84d00301985 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -37,6 +37,7 @@ export type PromptProps = { export type PromptRef = { focused: boolean + current: PromptInfo set(prompt: PromptInfo): void reset(): void blur(): void @@ -377,6 +378,9 @@ export function Prompt(props: PromptProps) { get focused() { return input.focused }, + get current() { + return store.prompt + }, focus() { input.focus() }, diff --git a/packages/opencode/src/cli/cmd/tui/context/prompt.tsx b/packages/opencode/src/cli/cmd/tui/context/prompt.tsx new file mode 100644 index 00000000000..efbb050645e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/prompt.tsx @@ -0,0 +1,18 @@ +import { createSimpleContext } from "./helper" +import type { PromptRef } from "../component/prompt" + +export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleContext({ + name: "PromptRef", + init: () => { + let current: PromptRef | undefined + + return { + get current() { + return current + }, + set(ref: PromptRef | undefined) { + current = ref + }, + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index b906de99b38..22333a0589e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -1,8 +1,10 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" +import type { PromptInfo } from "../component/prompt/history" export type HomeRoute = { type: "home" + initialPrompt?: PromptInfo } export type SessionRoute = { diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 33942c2a50b..df99eac885f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,15 +1,14 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" -import { createMemo, Match, onMount, Show, Switch, type ParentProps } from "solid-js" +import { createMemo, Match, onMount, Show, Switch } from "solid-js" import { useTheme } from "@tui/context/theme" -import { useKeybind } from "../context/keybind" -import type { KeybindsConfig } from "@opencode-ai/sdk" import { Logo } from "../component/logo" import { Locale } from "@/util/locale" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" import { useArgs } from "../context/args" -import { Global } from "@/global" import { useDirectory } from "../context/directory" +import { useRoute, useRouteData } from "@tui/context/route" +import { usePromptRef } from "../context/prompt" // TODO: what is the best way to do this? let once = false @@ -17,6 +16,8 @@ let once = false export function Home() { const sync = useSync() const { theme } = useTheme() + const route = useRouteData("home") + const promptRef = usePromptRef() const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0) const mcpError = createMemo(() => { return Object.values(sync.data.mcp).some((x) => x.status === "failed") @@ -45,7 +46,10 @@ export function Home() { const args = useArgs() onMount(() => { if (once) return - if (args.prompt) { + if (route.initialPrompt) { + prompt.set(route.initialPrompt) + once = true + } else if (args.prompt) { prompt.set({ input: args.prompt, parts: [] }) once = true } @@ -57,7 +61,13 @@ export function Home() { - (prompt = r)} hint={Hint} /> + { + prompt = r + promptRef.set(r) + }} + hint={Hint} + /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5d9ebbc7a13..ede4e28384b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -63,6 +63,7 @@ import { useKV } from "../../context/kv.tsx" import { Editor } from "../../util/editor" import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" +import { usePromptRef } from "../../context/prompt" addDefaultParsers(parsers.parsers) @@ -99,6 +100,7 @@ export function Session() { const sync = useSync() const kv = useKV() const { theme } = useTheme() + const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)!) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) @@ -949,7 +951,10 @@ export function Session() { (prompt = r)} + ref={(r) => { + prompt = r + promptRef.set(r) + }} disabled={permissions().length > 0} onSubmit={() => { toBottom() From 350a32274a24e6f5ca3ea30be114a8171a58270f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 4 Dec 2025 11:15:30 -0600 Subject: [PATCH 10/23] fix: model not being passed correctly to tool --- packages/opencode/src/session/prompt.ts | 5 +++-- packages/opencode/src/tool/read.ts | 10 ++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index d82cbd718c3..ebf0a57d002 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -690,7 +690,7 @@ export namespace SessionPrompt { abort: options.abortSignal!, messageID: input.processor.message.id, callID: options.toolCallId, - extra: input.model, + extra: { model: input.model }, agent: input.agent.name, metadata: async (val) => { const match = input.processor.partFromToolCall(options.toolCallId) @@ -907,12 +907,13 @@ export namespace SessionPrompt { await ReadTool.init() .then(async (t) => { + const model = await Provider.getModel(info.model.providerID, info.model.modelID) const result = await t.execute(args, { sessionID: input.sessionID, abort: new AbortController().signal, agent: input.agent!, messageID: info.id, - extra: { bypassCwdCheck: true, ...info.model }, + extra: { bypassCwdCheck: true, model }, metadata: async () => {}, }) pieces.push({ diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 7e01246b539..7d01a198148 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -95,14 +95,8 @@ export const ReadTool = Tool.define("read", { } const isImage = isImageFile(filepath) - const supportsImages = await (async () => { - if (!ctx.extra?.["providerID"] || !ctx.extra?.["modelID"]) return false - const providerID = ctx.extra["providerID"] as string - const modelID = ctx.extra["modelID"] as string - const model = await Provider.getModel(providerID, modelID).catch(() => undefined) - if (!model) return false - return model.capabilities.input.image - })() + const model = ctx.extra?.model as Provider.Model | undefined + const supportsImages = model?.capabilities.input.image ?? false if (isImage) { if (!supportsImages) { throw new Error(`Failed to read image: ${filepath}, model may not be able to read images`) From a607f33552b653af5e7ab5233c2da3389b978e68 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 4 Dec 2025 17:33:00 +0000 Subject: [PATCH 11/23] tweak: bash tool messages regarding timeouts and truncation more clear for agent (#5066) --- packages/opencode/src/tool/bash.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index d8af0a77f46..fa737aaece8 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -316,17 +316,24 @@ export const BashTool = Tool.define("bash", async () => { }) }) + let resultMetadata: String[] = [""] + if (output.length > MAX_OUTPUT_LENGTH) { output = output.slice(0, MAX_OUTPUT_LENGTH) - output += "\n\n(Output was truncated due to length limit)" + resultMetadata.push(`Output exceeded length limit of ${MAX_OUTPUT_LENGTH} chars`) } if (timedOut) { - output += `\n\n(Command timed out after ${timeout} ms)` + resultMetadata.push(`Command terminated after exceeding timeout ${timeout} ms`) } if (aborted) { - output += "\n\n(Command was aborted)" + resultMetadata.push("Command aborted by user") + } + + if (resultMetadata.length > 1) { + resultMetadata.push("") + output += "\n\n" + resultMetadata.join("\n") } return { From a32cf70d7e28c24c1644053f4247a7ec1fd02aab Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 4 Dec 2025 12:01:13 -0600 Subject: [PATCH 12/23] tui: fix /new slash command being persisted in prompt input --- .../src/cli/cmd/tui/component/prompt/autocomplete.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index f74a176ecb2..c397bc23c00 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -352,8 +352,8 @@ export function Autocomplete(props: { function select() { const selected = options()[store.selected] if (!selected) return - selected.onSelect?.() hide() + selected.onSelect?.() } function show(mode: "@" | "/") { @@ -374,6 +374,10 @@ export function Autocomplete(props: { if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) { const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) + // Sync the prompt store immediately since onContentChange is async + props.setPrompt((draft) => { + draft.input = props.input().plainText + }) } command.keybinds(true) setStore("visible", false) From 7f86fe3f6141bef31cdab166a60269143b78dcc5 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Thu, 4 Dec 2025 15:10:56 -0300 Subject: [PATCH 13/23] add optional prompt Input to Github Action (#4828) Co-authored-by: Github Action Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- github/action.yml | 5 +++++ packages/opencode/src/cli/cmd/github.ts | 5 +++++ packages/web/src/content/docs/github.mdx | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/github/action.yml b/github/action.yml index 0b7367ded42..d22d19990ae 100644 --- a/github/action.yml +++ b/github/action.yml @@ -13,6 +13,10 @@ inputs: description: "Share the opencode session (defaults to true for public repos)" required: false + prompt: + description: "Custom prompt to override the default prompt" + required: false + runs: using: "composite" steps: @@ -27,3 +31,4 @@ runs: env: MODEL: ${{ inputs.model }} SHARE: ${{ inputs.share }} + PROMPT: ${{ inputs.prompt }} diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index b255e17d1b3..99bbb8cc49b 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -562,6 +562,11 @@ export const GithubRunCommand = cmd({ } async function getUserPrompt() { + const customPrompt = process.env["PROMPT"] + if (customPrompt) { + return { userPrompt: customPrompt, promptFiles: [] } + } + const reviewContext = getReviewCommentContext() let prompt = (() => { const body = payload.comment.body.trim() diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 19c7782ef04..b0e0397e114 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -82,6 +82,7 @@ Or you can set it up manually. - `model`: The model to use with opencode. Takes the format of `provider/model`. This is **required**. - `share`: Whether to share the opencode session. Defaults to **true** for public repositories. +- `prompt`: Optional custom prompt to override the default behavior. Use this to customize how opencode processes requests. - `token`: Optional GitHub access token for performing operations such as creating comments, committing changes, and opening pull requests. By default, opencode uses the installation access token from the opencode GitHub App, so commits, comments, and pull requests appear as coming from the app. Alternatively, you can use the GitHub Action runner's [built-in `GITHUB_TOKEN`](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token) without installing the opencode GitHub App. Just make sure to grant the required permissions in your workflow: @@ -98,6 +99,25 @@ Or you can set it up manually. --- +## Custom prompts + +Override the default prompt to customize opencode's behavior for your workflow. + +```yaml title=".github/workflows/opencode.yml" +- uses: sst/opencode/github@latest + with: + model: anthropic/claude-sonnet-4-5 + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements +``` + +This is useful for enforcing specific review criteria, coding standards, or focus areas relevant to your project. + +--- + ## Examples Here are some examples of how you can use opencode in GitHub. From 8a0c86cbdb0f8fb2a7cf281d5a1dffb3291f40c7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 4 Dec 2025 12:37:14 -0600 Subject: [PATCH 14/23] bump: builtin plugin versions --- packages/opencode/src/global/index.ts | 2 +- packages/opencode/src/plugin/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index de0d0182368..04a9093a529 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -30,7 +30,7 @@ await Promise.all([ fs.mkdir(Global.Path.bin, { recursive: true }), ]) -const CACHE_VERSION = "12" +const CACHE_VERSION = "13" const version = await Bun.file(path.join(Global.Path.cache, "version")) .text() diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e617e045418..9571a63481d 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -28,8 +28,8 @@ export namespace Plugin { } const plugins = [...(config.plugin ?? [])] if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { - plugins.push("opencode-copilot-auth@0.0.7") - plugins.push("opencode-anthropic-auth@0.0.3") + plugins.push("opencode-copilot-auth@0.0.8") + plugins.push("opencode-anthropic-auth@0.0.4") } for (let plugin of plugins) { log.info("loading plugin", { path: plugin }) From b9c1f100160d663e6372cf114d1d86d4c9575237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 4 Dec 2025 21:07:23 +0100 Subject: [PATCH 15/23] feat: Add SAP AI Core provider support (#5023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jérôme Benoit --- packages/opencode/src/provider/provider.ts | 13 ++++- packages/web/src/content/docs/providers.mdx | 55 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 54367bcfeb3..ee081d87e47 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -285,6 +285,17 @@ export namespace Provider { }, } }, + "sap-ai-core": async () => { + const auth = await Auth.get("sap-ai-core") + const serviceKey = Env.get("SAP_AI_SERVICE_KEY") || (auth?.type === "api" ? auth.key : undefined) + const deploymentId = Env.get("SAP_AI_DEPLOYMENT_ID") || "d65d81e7c077e583" + const resourceGroup = Env.get("SAP_AI_RESOURCE_GROUP") || "default" + + return { + autoload: !!serviceKey, + options: serviceKey ? { serviceKey, deploymentId, resourceGroup } : {}, + } + }, zenmux: async () => { return { autoload: false, @@ -776,7 +787,7 @@ export namespace Provider { const mod = await import(installedPath) const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] - const loaded = fn({ + const loaded = await fn({ name: model.providerID, ...options, }) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index bb284901f53..e8534ceee93 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -891,6 +891,61 @@ OpenCode Zen is a list of tested and verified models provided by the OpenCode te --- +### SAP AI Core + +SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon, Meta, Mistral, and AI21 through a unified platform. + +1. Go to your [SAP BTP Cockpit](https://account.hana.ondemand.com/), navigate to your SAP AI Core service instance, and create a service key. + + :::tip + The service key is a JSON object containing `clientid`, `clientsecret`, `url`, and `serviceurls.AI_API_URL`. You can find your AI Core instance under **Services** > **Instances and Subscriptions** in the BTP Cockpit. + ::: + +2. Run the `/connect` command and search for **SAP AI Core**. + + ```txt + /connect + ``` + +3. Enter your service key JSON. + + ```txt + ┌ Service key + │ + │ + └ enter + ``` + + Or set the `SAP_AI_SERVICE_KEY` environment variable: + + ```bash + SAP_AI_SERVICE_KEY='{"clientid":"...","clientsecret":"...","url":"...","serviceurls":{"AI_API_URL":"..."}}' opencode + ``` + + Or add it to your bash profile: + + ```bash title="~/.bash_profile" + export SAP_AI_SERVICE_KEY='{"clientid":"...","clientsecret":"...","url":"...","serviceurls":{"AI_API_URL":"..."}}' + ``` + +4. Optionally set deployment ID and resource group: + + ```bash + SAP_AI_DEPLOYMENT_ID=your-deployment-id SAP_AI_RESOURCE_GROUP=your-resource-group opencode + ``` + + :::note + If not set, uses deployment ID `d65d81e7c077e583` (general-purpose) and resource group `default`. Configure these for your specific setup. + ::: + +5. Run the `/models` command to select from 40+ available models. + + ```txt + /models + ``` + +--- + ### OVHcloud AI Endpoints 1. Head over to the [OVHcloud panel](https://ovh.com/manager). Navigate to the `Public Cloud` section, `AI & Machine Learning` > `AI Endpoints` and in `API Keys` tab, click **Create a new API key**. From 668d5a76d54d4b137264d79048ea0b6c9d0ce24d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Dec 2025 15:39:52 -0500 Subject: [PATCH 16/23] core: ensure model npm package falls back to dev models config when not explicitly defined --- packages/opencode/src/provider/provider.ts | 60 +++++++++++----------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index ee081d87e47..f60bcfa4756 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -457,7 +457,8 @@ export namespace Provider { const state = Instance.state(async () => { using _ = log.time("state") const config = await Config.get() - const database = mapValues(await ModelsDev.get(), fromModelsDevProvider) + const modelsDev = await ModelsDev.get() + const database = mapValues(modelsDev, fromModelsDevProvider) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null @@ -515,56 +516,57 @@ export namespace Provider { } for (const [modelID, model] of Object.entries(provider.models ?? {})) { - const existing = parsed.models[model.id ?? modelID] + const existingModel = parsed.models[model.id ?? modelID] const name = iife(() => { if (model.name) return model.name if (model.id && model.id !== modelID) return modelID - return existing?.name ?? modelID + return existingModel?.name ?? modelID }) const parsedModel: Model = { id: modelID, api: { - id: model.id ?? existing?.api.id ?? modelID, - npm: model.provider?.npm ?? provider.npm ?? existing?.api.npm ?? providerID, - url: provider?.api ?? existing?.api.url, + id: model.id ?? existingModel?.api.id ?? modelID, + npm: + model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? modelsDev[providerID]?.npm ?? providerID, + url: provider?.api ?? existingModel?.api.url, }, - status: model.status ?? existing?.status ?? "active", + status: model.status ?? existingModel?.status ?? "active", name, providerID, capabilities: { - temperature: model.temperature ?? existing?.capabilities.temperature ?? false, - reasoning: model.reasoning ?? existing?.capabilities.reasoning ?? false, - attachment: model.attachment ?? existing?.capabilities.attachment ?? false, - toolcall: model.tool_call ?? existing?.capabilities.toolcall ?? true, + temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, + reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, + attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, + toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, input: { - text: model.modalities?.input?.includes("text") ?? existing?.capabilities.input.text ?? true, - audio: model.modalities?.input?.includes("audio") ?? existing?.capabilities.input.audio ?? false, - image: model.modalities?.input?.includes("image") ?? existing?.capabilities.input.image ?? false, - video: model.modalities?.input?.includes("video") ?? existing?.capabilities.input.video ?? false, - pdf: model.modalities?.input?.includes("pdf") ?? existing?.capabilities.input.pdf ?? false, + text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, + audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, + image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, + video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, + pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, }, output: { - text: model.modalities?.output?.includes("text") ?? existing?.capabilities.output.text ?? true, - audio: model.modalities?.output?.includes("audio") ?? existing?.capabilities.output.audio ?? false, - image: model.modalities?.output?.includes("image") ?? existing?.capabilities.output.image ?? false, - video: model.modalities?.output?.includes("video") ?? existing?.capabilities.output.video ?? false, - pdf: model.modalities?.output?.includes("pdf") ?? existing?.capabilities.output.pdf ?? false, + text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, + audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false, + image: model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false, + video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false, + pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, }, }, cost: { - input: model?.cost?.input ?? existing?.cost?.input ?? 0, - output: model?.cost?.output ?? existing?.cost?.output ?? 0, + input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, + output: model?.cost?.output ?? existingModel?.cost?.output ?? 0, cache: { - read: model?.cost?.cache_read ?? existing?.cost?.cache.read ?? 0, - write: model?.cost?.cache_write ?? existing?.cost?.cache.write ?? 0, + read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, + write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, }, }, - options: mergeDeep(existing?.options ?? {}, model.options ?? {}), + options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), limit: { - context: model.limit?.context ?? existing?.limit?.context ?? 0, - output: model.limit?.output ?? existing?.limit?.output ?? 0, + context: model.limit?.context ?? existingModel?.limit?.context ?? 0, + output: model.limit?.output ?? existingModel?.limit?.output ?? 0, }, - headers: mergeDeep(existing?.headers ?? {}, model.headers ?? {}), + headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), } parsed.models[modelID] = parsedModel } From 48dc520fb85387cf9754f0a80c0e3ce283f6a09c Mon Sep 17 00:00:00 2001 From: Cason Adams Date: Thu, 4 Dec 2025 13:49:51 -0700 Subject: [PATCH 17/23] docs: add CodeCompanion.nvim integration instructions (#5079) --- packages/web/src/content/docs/acp.mdx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/web/src/content/docs/acp.mdx b/packages/web/src/content/docs/acp.mdx index bca30923c25..9d421235c1b 100644 --- a/packages/web/src/content/docs/acp.mdx +++ b/packages/web/src/content/docs/acp.mdx @@ -100,6 +100,27 @@ If you need to pass environment variables: --- +### CodeCompanion.nvim + +To use OpenCode as an ACP agent in [CodeCompanion.nvim](https://github.com/olimorris/codecompanion.nvim), add the following to your Neovim config: + +```lua +require("codecompanion").setup({ + strategies = { + chat = { + adapter = { + name = "opencode", + model = "claude-sonnet-4", + }, + }, + }, +}) +``` + +This config sets up CodeCompanion to use OpenCode as the ACP agent for chat. + +If you need to pass environment variables (like `OPENCODE_API_KEY`), refer to [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/configuration/adapters#environment-variables-setting-an-api-key) in the CodeCompanion.nvim documentation for full details. + ## Support OpenCode works the same via ACP as it does in the terminal. All features are supported: From d469d7d44156ed83aca2607806856e128e71502f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 4 Dec 2025 15:21:55 -0600 Subject: [PATCH 18/23] tweak: bash tool description re commit stuff --- packages/opencode/src/tool/bash.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 0a4d9f16de1..cbb66bba532 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -35,6 +35,8 @@ Usage notes: # Committing changes with git +IMPORTANT: ONLY COMMIT IF THE USER ASKS YOU TO. + If and only if the user asks you to create a new git commit, follow these steps carefully: 1. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following bash commands in parallel, each using the Bash tool: @@ -42,9 +44,8 @@ If and only if the user asks you to create a new git commit, follow these steps - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. -2. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in tags: +2. Analyze all staged changes (both previously staged and newly added) and draft a commit message. When analyzing: - - List the files that have been changed or added - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.) - Brainstorm the purpose or motivation behind these changes @@ -55,7 +56,6 @@ If and only if the user asks you to create a new git commit, follow these steps - Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.) - Ensure the message is not generic (avoid words like "Update" or "Fix" without context) - Review the draft message to ensure it accurately reflects the changes and their purpose - 3. You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. ALWAYS run the following commands in parallel: - Add relevant untracked files to the staging area. From b1202ac6db1cbead1e8f205913524f1c47322970 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Dec 2025 16:30:54 -0500 Subject: [PATCH 19/23] core: add test for custom model npm package inheritance --- .../opencode/test/provider/provider.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 698fdddfb42..e6eb0c7b464 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1727,3 +1727,39 @@ test("provider options are deeply merged", async () => { }, }) }) + +test("custom model inherits npm package from models.dev provider config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + openai: { + models: { + "my-custom-model": { + name: "My Custom Model", + tool_call: true, + limit: { context: 8000, output: 2000 }, + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("OPENAI_API_KEY", "test-api-key") + }, + fn: async () => { + const providers = await Provider.list() + const model = providers["openai"].models["my-custom-model"] + expect(model).toBeDefined() + expect(model.api.npm).toBe("@ai-sdk/openai") + }, + }) +}) From d763c11a6d5bc57869f11c87f5a293f61e427e0a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:37:29 -0600 Subject: [PATCH 20/23] feat(desktop): terminal pane (#5081) Co-authored-by: Github Action Co-authored-by: Dax Raad --- bun.lock | 13 +- flake.lock | 6 +- nix/hashes.json | 2 +- package.json | 2 +- packages/desktop/package.json | 2 + packages/desktop/src/addons/serialize.ts | 649 +++++++++++++++++++ packages/desktop/src/components/terminal.tsx | 151 +++++ packages/desktop/src/context/layout.tsx | 22 +- packages/desktop/src/context/sdk.tsx | 2 +- packages/desktop/src/context/session.tsx | 100 ++- packages/desktop/src/pages/layout.tsx | 114 +++- packages/desktop/src/pages/session.tsx | 649 +++++++++++-------- packages/opencode/package.json | 1 + packages/opencode/src/cli/cmd/tui/worker.ts | 7 +- packages/opencode/src/id/id.ts | 1 + packages/opencode/src/pty/index.ts | 199 ++++++ packages/opencode/src/server/error.ts | 36 + packages/opencode/src/server/server.ts | 197 +++++- packages/sdk/js/src/gen/sdk.gen.ts | 88 +++ packages/sdk/js/src/gen/types.gen.ts | 248 ++++++- packages/ui/src/components/icon.tsx | 7 +- packages/ui/src/components/select.css | 2 + packages/ui/src/components/select.tsx | 56 +- packages/ui/src/components/tabs.css | 96 ++- packages/ui/src/components/tabs.tsx | 17 +- packages/ui/src/components/tooltip.css | 7 +- packages/util/src/shell.ts | 13 + 27 files changed, 2292 insertions(+), 395 deletions(-) create mode 100644 packages/desktop/src/addons/serialize.ts create mode 100644 packages/desktop/src/components/terminal.tsx create mode 100644 packages/opencode/src/pty/index.ts create mode 100644 packages/opencode/src/server/error.ts create mode 100644 packages/util/src/shell.ts diff --git a/bun.lock b/bun.lock index aad651621cb..ff4f1444705 100644 --- a/bun.lock +++ b/bun.lock @@ -135,11 +135,13 @@ "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", + "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "fuzzysort": "catalog:", + "ghostty-web": "0.3.0", "luxon": "catalog:", "marked": "16.2.0", "marked-shiki": "1.2.1", @@ -246,6 +248,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", "decimal.js": "10.5.0", @@ -457,7 +460,7 @@ "ai": "5.0.97", "diff": "8.0.2", "fuzzysort": "3.1.0", - "hono": "4.7.10", + "hono": "4.10.7", "hono-openapi": "1.1.1", "luxon": "3.6.1", "remeda": "2.26.0", @@ -1506,6 +1509,8 @@ "@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="], + "@solid-primitives/websocket": ["@solid-primitives/websocket@1.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-F06tA2FKa5VsnS4E4WEc3jHpsJfXRlMTGOtolugTzCqV3JmJTyvk9UVg1oz6PgGHKGi1CQ91OP8iW34myyJgaQ=="], + "@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="], "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], @@ -1890,6 +1895,8 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="], + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], @@ -2334,6 +2341,8 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], "giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], @@ -2428,7 +2437,7 @@ "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="], - "hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="], + "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], "hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="], diff --git a/flake.lock b/flake.lock index 4e7cf41e1b7..ca9fd5f8f30 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1764733908, - "narHash": "sha256-QJiih52NU+nm7XQWCj+K8SwUdIEayDQ1FQgjkYISt4I=", + "lastModified": 1764794580, + "narHash": "sha256-UMVihg0OQ980YqmOAPz+zkuCEb9hpE5Xj2v+ZGNjQ+M=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "cadcc8de247676e4751c9d4a935acb2c0b059113", + "rev": "ebc94f855ef25347c314258c10393a92794e7ab9", "type": "github" }, "original": { diff --git a/nix/hashes.json b/nix/hashes.json index 47634e2ed82..6bc2eaec159 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-ZGKC7h4ScHDzVYj8qb1lN/weZhyZivPS8kpNAZvgO0I=" + "nodeModules": "sha256-Wrfwnmo0lpck2rbt6ttkAuDGvBvqqWJfNA8QDQxoZ6I=" } diff --git a/package.json b/package.json index a5e7c14621b..a962be92605 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "ai": "5.0.97", - "hono": "4.7.10", + "hono": "4.10.7", "hono-openapi": "1.1.1", "fuzzysort": "3.1.0", "luxon": "3.6.1", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f2f8768cbb8..addff88b046 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -33,11 +33,13 @@ "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", + "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "fuzzysort": "catalog:", + "ghostty-web": "0.3.0", "luxon": "catalog:", "marked": "16.2.0", "marked-shiki": "1.2.1", diff --git a/packages/desktop/src/addons/serialize.ts b/packages/desktop/src/addons/serialize.ts new file mode 100644 index 00000000000..03899ff109b --- /dev/null +++ b/packages/desktop/src/addons/serialize.ts @@ -0,0 +1,649 @@ +/** + * SerializeAddon - Serialize terminal buffer contents + * + * Port of xterm.js addon-serialize for ghostty-web. + * Enables serialization of terminal contents to a string that can + * be written back to restore terminal state. + * + * Usage: + * ```typescript + * const serializeAddon = new SerializeAddon(); + * term.loadAddon(serializeAddon); + * const content = serializeAddon.serialize(); + * ``` + */ + +import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web" + +// ============================================================================ +// Buffer Types (matching ghostty-web internal interfaces) +// ============================================================================ + +interface IBuffer { + readonly type: "normal" | "alternate" + readonly cursorX: number + readonly cursorY: number + readonly viewportY: number + readonly baseY: number + readonly length: number + getLine(y: number): IBufferLine | undefined + getNullCell(): IBufferCell +} + +interface IBufferLine { + readonly length: number + readonly isWrapped: boolean + getCell(x: number): IBufferCell | undefined + translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string +} + +interface IBufferCell { + getChars(): string + getCode(): number + getWidth(): number + getFgColorMode(): number + getBgColorMode(): number + getFgColor(): number + getBgColor(): number + isBold(): number + isItalic(): number + isUnderline(): number + isStrikethrough(): number + isBlink(): number + isInverse(): number + isInvisible(): number + isFaint(): number + isDim(): boolean +} + +// ============================================================================ +// Types +// ============================================================================ + +export interface ISerializeOptions { + /** + * The row range to serialize. When an explicit range is specified, the cursor + * will get its final repositioning. + */ + range?: ISerializeRange + /** + * The number of rows in the scrollback buffer to serialize, starting from + * the bottom of the scrollback buffer. When not specified, all available + * rows in the scrollback buffer will be serialized. + */ + scrollback?: number + /** + * Whether to exclude the terminal modes from the serialization. + * Default: false + */ + excludeModes?: boolean + /** + * Whether to exclude the alt buffer from the serialization. + * Default: false + */ + excludeAltBuffer?: boolean +} + +export interface ISerializeRange { + /** + * The line to start serializing (inclusive). + */ + start: number + /** + * The line to end serializing (inclusive). + */ + end: number +} + +export interface IHTMLSerializeOptions { + /** + * The number of rows in the scrollback buffer to serialize, starting from + * the bottom of the scrollback buffer. + */ + scrollback?: number + /** + * Whether to only serialize the selection. + * Default: false + */ + onlySelection?: boolean + /** + * Whether to include the global background of the terminal. + * Default: false + */ + includeGlobalBackground?: boolean + /** + * The range to serialize. This is prioritized over onlySelection. + */ + range?: { + startLine: number + endLine: number + startCol: number + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function constrain(value: number, low: number, high: number): number { + return Math.max(low, Math.min(value, high)) +} + +function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean { + return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor() +} + +function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean { + return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor() +} + +function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean { + return ( + !!cell1.isInverse() === !!cell2.isInverse() && + !!cell1.isBold() === !!cell2.isBold() && + !!cell1.isUnderline() === !!cell2.isUnderline() && + !!cell1.isBlink() === !!cell2.isBlink() && + !!cell1.isInvisible() === !!cell2.isInvisible() && + !!cell1.isItalic() === !!cell2.isItalic() && + !!cell1.isDim() === !!cell2.isDim() && + !!cell1.isStrikethrough() === !!cell2.isStrikethrough() + ) +} + +// ============================================================================ +// Base Serialize Handler +// ============================================================================ + +abstract class BaseSerializeHandler { + constructor(protected readonly _buffer: IBuffer) {} + + public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string { + let oldCell = this._buffer.getNullCell() + + const startRow = range.start.y + const endRow = range.end.y + const startColumn = range.start.x + const endColumn = range.end.x + + this._beforeSerialize(endRow - startRow, startRow, endRow) + + for (let row = startRow; row <= endRow; row++) { + const line = this._buffer.getLine(row) + if (line) { + const startLineColumn = row === range.start.y ? startColumn : 0 + const endLineColumn = row === range.end.y ? endColumn : line.length + for (let col = startLineColumn; col < endLineColumn; col++) { + const c = line.getCell(col) + if (!c) { + continue + } + this._nextCell(c, oldCell, row, col) + oldCell = c + } + } + this._rowEnd(row, row === endRow) + } + + this._afterSerialize() + + return this._serializeString(excludeFinalCursorPosition) + } + + protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {} + protected _rowEnd(_row: number, _isLastRow: boolean): void {} + protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {} + protected _afterSerialize(): void {} + protected _serializeString(_excludeFinalCursorPosition?: boolean): string { + return "" + } +} + +// ============================================================================ +// String Serialize Handler +// ============================================================================ + +class StringSerializeHandler extends BaseSerializeHandler { + private _rowIndex: number = 0 + private _allRows: string[] = [] + private _allRowSeparators: string[] = [] + private _currentRow: string = "" + private _nullCellCount: number = 0 + private _cursorStyle: IBufferCell + private _cursorStyleRow: number = 0 + private _cursorStyleCol: number = 0 + private _backgroundCell: IBufferCell + private _firstRow: number = 0 + private _lastCursorRow: number = 0 + private _lastCursorCol: number = 0 + private _lastContentCursorRow: number = 0 + private _lastContentCursorCol: number = 0 + private _thisRowLastChar: IBufferCell + private _thisRowLastSecondChar: IBufferCell + private _nextRowFirstChar: IBufferCell + + constructor( + buffer: IBuffer, + private readonly _terminal: ITerminalCore, + ) { + super(buffer) + this._cursorStyle = this._buffer.getNullCell() + this._backgroundCell = this._buffer.getNullCell() + this._thisRowLastChar = this._buffer.getNullCell() + this._thisRowLastSecondChar = this._buffer.getNullCell() + this._nextRowFirstChar = this._buffer.getNullCell() + } + + protected _beforeSerialize(rows: number, start: number, _end: number): void { + this._allRows = new Array(rows) + this._lastContentCursorRow = start + this._lastCursorRow = start + this._firstRow = start + } + + protected _rowEnd(row: number, isLastRow: boolean): void { + // if there is colorful empty cell at line end, we must pad it back + if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) { + this._currentRow += `\u001b[${this._nullCellCount}X` + } + + let rowSeparator = "" + + if (!isLastRow) { + // Enable BCE + if (row - this._firstRow >= this._terminal.rows) { + const line = this._buffer.getLine(this._cursorStyleRow) + const cell = line?.getCell(this._cursorStyleCol) + if (cell) { + this._backgroundCell = cell + } + } + + const currentLine = this._buffer.getLine(row)! + const nextLine = this._buffer.getLine(row + 1)! + + if (!nextLine.isWrapped) { + rowSeparator = "\r\n" + this._lastCursorRow = row + 1 + this._lastCursorCol = 0 + } else { + rowSeparator = "" + const thisRowLastChar = currentLine.getCell(currentLine.length - 1) + const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2) + const nextRowFirstChar = nextLine.getCell(0) + + if (thisRowLastChar) this._thisRowLastChar = thisRowLastChar + if (thisRowLastSecondChar) this._thisRowLastSecondChar = thisRowLastSecondChar + if (nextRowFirstChar) this._nextRowFirstChar = nextRowFirstChar + + const isNextRowFirstCharDoubleWidth = this._nextRowFirstChar.getWidth() > 1 + + let isValid = false + + if ( + this._nextRowFirstChar.getChars() && + (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0) + ) { + if ( + (this._thisRowLastChar.getChars() || this._thisRowLastChar.getWidth() === 0) && + equalBg(this._thisRowLastChar, this._nextRowFirstChar) + ) { + isValid = true + } + + if ( + isNextRowFirstCharDoubleWidth && + (this._thisRowLastSecondChar.getChars() || this._thisRowLastSecondChar.getWidth() === 0) && + equalBg(this._thisRowLastChar, this._nextRowFirstChar) && + equalBg(this._thisRowLastSecondChar, this._nextRowFirstChar) + ) { + isValid = true + } + } + + if (!isValid) { + rowSeparator = "-".repeat(this._nullCellCount + 1) + rowSeparator += "\u001b[1D\u001b[1X" + + if (this._nullCellCount > 0) { + rowSeparator += "\u001b[A" + rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C` + rowSeparator += `\u001b[${this._nullCellCount}X` + rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D` + rowSeparator += "\u001b[B" + } + + this._lastContentCursorRow = row + 1 + this._lastContentCursorCol = 0 + this._lastCursorRow = row + 1 + this._lastCursorCol = 0 + } + } + } + + this._allRows[this._rowIndex] = this._currentRow + this._allRowSeparators[this._rowIndex++] = rowSeparator + this._currentRow = "" + this._nullCellCount = 0 + } + + private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] { + const sgrSeq: number[] = [] + const fgChanged = !equalFg(cell, oldCell) + const bgChanged = !equalBg(cell, oldCell) + const flagsChanged = !equalFlags(cell, oldCell) + + if (fgChanged || bgChanged || flagsChanged) { + if (this._isAttributeDefault(cell)) { + if (!this._isAttributeDefault(oldCell)) { + sgrSeq.push(0) + } + } else { + if (fgChanged) { + const color = cell.getFgColor() + const mode = cell.getFgColorMode() + if (mode === 2) { + // RGB + sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) + } else if (mode === 1) { + // Palette + if (color >= 16) { + sgrSeq.push(38, 5, color) + } else { + sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)) + } + } else { + sgrSeq.push(39) + } + } + if (bgChanged) { + const color = cell.getBgColor() + const mode = cell.getBgColorMode() + if (mode === 2) { + // RGB + sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) + } else if (mode === 1) { + // Palette + if (color >= 16) { + sgrSeq.push(48, 5, color) + } else { + sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)) + } + } else { + sgrSeq.push(49) + } + } + if (flagsChanged) { + if (!!cell.isInverse() !== !!oldCell.isInverse()) { + sgrSeq.push(cell.isInverse() ? 7 : 27) + } + if (!!cell.isBold() !== !!oldCell.isBold()) { + sgrSeq.push(cell.isBold() ? 1 : 22) + } + if (!!cell.isUnderline() !== !!oldCell.isUnderline()) { + sgrSeq.push(cell.isUnderline() ? 4 : 24) + } + if (!!cell.isBlink() !== !!oldCell.isBlink()) { + sgrSeq.push(cell.isBlink() ? 5 : 25) + } + if (!!cell.isInvisible() !== !!oldCell.isInvisible()) { + sgrSeq.push(cell.isInvisible() ? 8 : 28) + } + if (!!cell.isItalic() !== !!oldCell.isItalic()) { + sgrSeq.push(cell.isItalic() ? 3 : 23) + } + if (!!cell.isDim() !== !!oldCell.isDim()) { + sgrSeq.push(cell.isDim() ? 2 : 22) + } + if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) { + sgrSeq.push(cell.isStrikethrough() ? 9 : 29) + } + } + } + } + + return sgrSeq + } + + private _isAttributeDefault(cell: IBufferCell): boolean { + return ( + cell.getFgColorMode() === 0 && + cell.getBgColorMode() === 0 && + !cell.isBold() && + !cell.isItalic() && + !cell.isUnderline() && + !cell.isBlink() && + !cell.isInverse() && + !cell.isInvisible() && + !cell.isDim() && + !cell.isStrikethrough() + ) + } + + protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void { + const isPlaceHolderCell = cell.getWidth() === 0 + + if (isPlaceHolderCell) { + return + } + + const isEmptyCell = cell.getChars() === "" + + const sgrSeq = this._diffStyle(cell, this._cursorStyle) + + const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0 + + if (styleChanged) { + if (this._nullCellCount > 0) { + if (!equalBg(this._cursorStyle, this._backgroundCell)) { + this._currentRow += `\u001b[${this._nullCellCount}X` + } + this._currentRow += `\u001b[${this._nullCellCount}C` + this._nullCellCount = 0 + } + + this._lastContentCursorRow = this._lastCursorRow = row + this._lastContentCursorCol = this._lastCursorCol = col + + this._currentRow += `\u001b[${sgrSeq.join(";")}m` + + const line = this._buffer.getLine(row) + const cellFromLine = line?.getCell(col) + if (cellFromLine) { + this._cursorStyle = cellFromLine + this._cursorStyleRow = row + this._cursorStyleCol = col + } + } + + if (isEmptyCell) { + this._nullCellCount += cell.getWidth() + } else { + if (this._nullCellCount > 0) { + if (equalBg(this._cursorStyle, this._backgroundCell)) { + this._currentRow += `\u001b[${this._nullCellCount}C` + } else { + this._currentRow += `\u001b[${this._nullCellCount}X` + this._currentRow += `\u001b[${this._nullCellCount}C` + } + this._nullCellCount = 0 + } + + this._currentRow += cell.getChars() + + this._lastContentCursorRow = this._lastCursorRow = row + this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth() + } + } + + protected _serializeString(excludeFinalCursorPosition?: boolean): string { + let rowEnd = this._allRows.length + + if (this._buffer.length - this._firstRow <= this._terminal.rows) { + rowEnd = this._lastContentCursorRow + 1 - this._firstRow + this._lastCursorCol = this._lastContentCursorCol + this._lastCursorRow = this._lastContentCursorRow + } + + let content = "" + + for (let i = 0; i < rowEnd; i++) { + content += this._allRows[i] + if (i + 1 < rowEnd) { + content += this._allRowSeparators[i] + } + } + + if (!excludeFinalCursorPosition) { + // Get cursor position relative to viewport (1-indexed for ANSI) + // cursorY is relative to the viewport, cursorX is column position + const cursorRow = this._buffer.cursorY + 1 // 1-indexed + const cursorCol = this._buffer.cursorX + 1 // 1-indexed + + // Use absolute cursor positioning (CUP - Cursor Position) + // This is more reliable than relative moves which depend on knowing + // exactly where the cursor ended up after all the content + content += `\u001b[${cursorRow};${cursorCol}H` + } + + return content + } +} + +// ============================================================================ +// SerializeAddon Class +// ============================================================================ + +export class SerializeAddon implements ITerminalAddon { + private _terminal?: ITerminalCore + + /** + * Activate the addon (called by Terminal.loadAddon) + */ + public activate(terminal: ITerminalCore): void { + this._terminal = terminal + } + + /** + * Dispose the addon and clean up resources + */ + public dispose(): void { + this._terminal = undefined + } + + /** + * Serializes terminal rows into a string that can be written back to the + * terminal to restore the state. The cursor will also be positioned to the + * correct cell. + * + * @param options Custom options to allow control over what gets serialized. + */ + public serialize(options?: ISerializeOptions): string { + if (!this._terminal) { + throw new Error("Cannot use addon until it has been loaded") + } + + const terminal = this._terminal as any + const buffer = terminal.buffer + + if (!buffer) { + return "" + } + + const activeBuffer = buffer.active || buffer.normal + if (!activeBuffer) { + return "" + } + + let content = options?.range + ? this._serializeBufferByRange(activeBuffer, options.range, true) + : this._serializeBufferByScrollback(activeBuffer, options?.scrollback) + + // Handle alternate buffer if active and not excluded + if (!options?.excludeAltBuffer) { + const altBuffer = buffer.alternate + if (altBuffer && buffer.active?.type === "alternate") { + const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined) + content += `\u001b[?1049h\u001b[H${alternateContent}` + } + } + + return content + } + + /** + * Serializes terminal content as plain text (no escape sequences) + * @param options Custom options to allow control over what gets serialized. + */ + public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string { + if (!this._terminal) { + throw new Error("Cannot use addon until it has been loaded") + } + + const terminal = this._terminal as any + const buffer = terminal.buffer + + if (!buffer) { + return "" + } + + const activeBuffer = buffer.active || buffer.normal + if (!activeBuffer) { + return "" + } + + const maxRows = activeBuffer.length + const scrollback = options?.scrollback + const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows) + + const startRow = maxRows - correctRows + const endRow = maxRows - 1 + const lines: string[] = [] + + for (let row = startRow; row <= endRow; row++) { + const line = activeBuffer.getLine(row) + if (line) { + const text = line.translateToString(options?.trimWhitespace ?? true) + lines.push(text) + } + } + + // Trim trailing empty lines if requested + if (options?.trimWhitespace) { + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop() + } + } + + return lines.join("\n") + } + + private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string { + const maxRows = buffer.length + const rows = this._terminal?.rows ?? 24 + const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows) + return this._serializeBufferByRange( + buffer, + { + start: maxRows - correctRows, + end: maxRows - 1, + }, + false, + ) + } + + private _serializeBufferByRange( + buffer: IBuffer, + range: ISerializeRange, + excludeFinalCursorPosition: boolean, + ): string { + const handler = new StringSerializeHandler(buffer, this._terminal!) + const cols = this._terminal?.cols ?? 80 + return handler.serialize( + { + start: { x: 0, y: range.start }, + end: { x: cols, y: range.end }, + }, + excludeFinalCursorPosition, + ) + } +} diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx new file mode 100644 index 00000000000..49a45a432bc --- /dev/null +++ b/packages/desktop/src/components/terminal.tsx @@ -0,0 +1,151 @@ +import { init, Terminal as Term, FitAddon } from "ghostty-web" +import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" +import { createReconnectingWS, ReconnectingWebSocket } from "@solid-primitives/websocket" +import { useSDK } from "@/context/sdk" +import { SerializeAddon } from "@/addons/serialize" +import { LocalPTY } from "@/context/session" + +await init() + +export interface TerminalProps extends ComponentProps<"div"> { + pty: LocalPTY + onSubmit?: () => void + onCleanup?: (pty: LocalPTY) => void +} + +export const Terminal = (props: TerminalProps) => { + const sdk = useSDK() + let container!: HTMLDivElement + const [local, others] = splitProps(props, ["pty", "class", "classList"]) + let ws: ReconnectingWebSocket + let term: Term + let serializeAddon: SerializeAddon + let fitAddon: FitAddon + + onMount(async () => { + ws = createReconnectingWS(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) + term = new Term({ + cursorBlink: true, + fontSize: 14, + fontFamily: "TX-02, monospace", + allowTransparency: true, + theme: { + background: "#191515", + foreground: "#d4d4d4", + }, + scrollback: 10_000, + }) + term.attachCustomKeyEventHandler((event) => { + // allow for ctrl-` to toggle terminal in parent + if (event.ctrlKey && event.key.toLowerCase() === "`") { + event.preventDefault() + return true + } + return false + }) + + fitAddon = new FitAddon() + serializeAddon = new SerializeAddon() + term.loadAddon(serializeAddon) + term.loadAddon(fitAddon) + + term.open(container) + + if (local.pty.buffer) { + const originalSize = { cols: term.cols, rows: term.rows } + let resized = false + if (local.pty.rows && local.pty.cols) { + term.resize(local.pty.cols, local.pty.rows) + resized = true + } + term.write(local.pty.buffer) + if (local.pty.scrollY) { + term.scrollToLine(local.pty.scrollY) + } + if (resized) { + term.resize(originalSize.cols, originalSize.rows) + } + } + + container.focus() + + fitAddon.fit() + fitAddon.observeResize() + window.addEventListener("resize", () => fitAddon.fit()) + term.onResize(async (size) => { + if (ws && ws.readyState === WebSocket.OPEN) { + await sdk.client.pty.update({ + path: { id: local.pty.id }, + body: { + size: { + cols: size.cols, + rows: size.rows, + }, + }, + }) + } + }) + term.onData((data) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(data) + } + }) + term.onKey((key) => { + if (key.key == "Enter") { + props.onSubmit?.() + } + }) + // term.onScroll((ydisp) => { + // console.log("Scroll position:", ydisp) + // }) + ws.addEventListener("open", () => { + console.log("WebSocket connected") + sdk.client.pty.update({ + path: { id: local.pty.id }, + body: { + size: { + cols: term.cols, + rows: term.rows, + }, + }, + }) + }) + ws.addEventListener("message", (event) => { + term.write(event.data) + }) + ws.addEventListener("error", (error) => { + console.error("WebSocket error:", error) + }) + ws.addEventListener("close", () => { + console.log("WebSocket disconnected") + }) + }) + + onCleanup(() => { + if (serializeAddon && props.onCleanup) { + const buffer = serializeAddon.serialize() + props.onCleanup({ + ...local.pty, + buffer, + rows: term.rows, + cols: term.cols, + scrollY: term.getViewportY(), + }) + } + ws?.close() + term?.dispose() + }) + + return ( +
+ ) +} diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 81e8b537abc..ca736e84e6b 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -15,12 +15,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( opened: true, width: 280, }, + terminal: { + opened: false, + height: 280, + }, review: { state: "pane" as "pane" | "tab", }, }), { - name: "___default-layout", + name: "____default-layout", }, ) @@ -61,6 +65,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("sidebar", "width", width) }, }, + terminal: { + opened: createMemo(() => store.terminal.opened), + open() { + setStore("terminal", "opened", true) + }, + close() { + setStore("terminal", "opened", false) + }, + toggle() { + setStore("terminal", "opened", (x) => !x) + }, + height: createMemo(() => store.terminal.height), + resize(height: number) { + setStore("terminal", "height", height) + }, + }, review: { state: createMemo(() => store.review?.state ?? "closed"), pane() { diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx index 81b32035a0b..144202ee209 100644 --- a/packages/desktop/src/context/sdk.tsx +++ b/packages/desktop/src/context/sdk.tsx @@ -27,6 +27,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ abort.abort() }) - return { directory: props.directory, client: sdk, event: emitter } + return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url } }, }) diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx index 72098a93951..4e9fe71f8a7 100644 --- a/packages/desktop/src/context/session.tsx +++ b/packages/desktop/src/context/session.tsx @@ -8,14 +8,25 @@ import { pipe, sumBy } from "remeda" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk" import { useParams } from "@solidjs/router" import { base64Encode } from "@/utils" +import { useSDK } from "./sdk" + +export type LocalPTY = { + id: string + title: string + rows?: number + cols?: number + buffer?: string + scrollY?: number +} export const { use: useSession, provider: SessionProvider } = createSimpleContext({ name: "Session", init: () => { + const sdk = useSDK() const params = useParams() const sync = useSync() const name = createMemo( - () => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`, + () => `______${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`, ) const [store, setStore] = makePersisted( @@ -23,16 +34,21 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex messageId?: string tabs: { active?: string - opened: string[] + all: string[] } prompt: Prompt cursor?: number + terminals: { + active?: string + all: LocalPTY[] + } }>({ tabs: { - opened: [], + all: [], }, prompt: clonePrompt(DEFAULT_PROMPT), cursor: undefined, + terminals: { all: [] }, }), { name: name(), @@ -138,7 +154,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex setStore("tabs", "active", tab) }, setOpenedTabs(tabs: string[]) { - setStore("tabs", "opened", tabs) + setStore("tabs", "all", tabs) }, async openTab(tab: string) { if (tab === "chat") { @@ -146,8 +162,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex return } if (tab !== "review") { - if (!store.tabs.opened.includes(tab)) { - setStore("tabs", "opened", [...store.tabs.opened, tab]) + if (!store.tabs.all.includes(tab)) { + setStore("tabs", "all", [...store.tabs.all, tab]) } } setStore("tabs", "active", tab) @@ -156,28 +172,88 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex batch(() => { setStore( "tabs", - "opened", - store.tabs.opened.filter((x) => x !== tab), + "all", + store.tabs.all.filter((x) => x !== tab), ) if (store.tabs.active === tab) { - const index = store.tabs.opened.findIndex((f) => f === tab) - const previous = store.tabs.opened[Math.max(0, index - 1)] + const index = store.tabs.all.findIndex((f) => f === tab) + const previous = store.tabs.all[Math.max(0, index - 1)] setStore("tabs", "active", previous) } }) }, moveTab(tab: string, to: number) { - const index = store.tabs.opened.findIndex((f) => f === tab) + const index = store.tabs.all.findIndex((f) => f === tab) if (index === -1) return setStore( "tabs", - "opened", + "all", produce((opened) => { opened.splice(to, 0, opened.splice(index, 1)[0]) }), ) }, }, + terminal: { + all: createMemo(() => Object.values(store.terminals.all)), + active: createMemo(() => store.terminals.active), + new() { + sdk.client.pty.create({ body: { title: `Terminal ${store.terminals.all.length + 1}` } }).then((pty) => { + const id = pty.data?.id + if (!id) return + batch(() => { + setStore("terminals", "all", [ + ...store.terminals.all, + { + id, + title: pty.data?.title ?? "Terminal", + // rows: pty.data?.rows ?? 24, + // cols: pty.data?.cols ?? 80, + // buffer: "", + // scrollY: 0, + }, + ]) + setStore("terminals", "active", id) + }) + }) + }, + update(pty: Partial & { id: string }) { + setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty.update({ + path: { id: pty.id }, + body: { title: pty.title, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined }, + }) + }, + open(id: string) { + setStore("terminals", "active", id) + }, + async close(id: string) { + batch(() => { + setStore( + "terminals", + "all", + store.terminals.all.filter((x) => x.id !== id), + ) + if (store.terminals.active === id) { + const index = store.terminals.all.findIndex((f) => f.id === id) + const previous = store.tabs.all[Math.max(0, index - 1)] + setStore("terminals", "active", previous) + } + }) + await sdk.client.pty.remove({ path: { id } }) + }, + move(id: string, to: number) { + const index = store.terminals.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "terminals", + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + }, } }, }) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 15180c88566..106a2e733fb 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,9 +1,9 @@ import { createMemo, For, ParentProps, Show } from "solid-js" import { DateTime } from "luxon" -import { A, useParams } from "@solidjs/router" +import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" -import { base64Encode } from "@/utils" +import { base64Decode, base64Encode } from "@/utils" import { Mark } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -12,11 +12,21 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" +import { Select } from "@opencode-ai/ui/select" +import { Session } from "@opencode-ai/sdk/client" export default function Layout(props: ParentProps) { + const navigate = useNavigate() const params = useParams() const globalSync = useGlobalSync() const layout = useLayout() + const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) + const currentSession = createMemo(() => sessions().find((s) => s.id === params.id) ?? sessions().at(0)) + + function navigateToSession(session: Session | undefined) { + navigate(`/${params.dir}/session/${session?.id}`) + } const handleOpenProject = async () => { // layout.projects.open(dir.) @@ -24,7 +34,7 @@ export default function Layout(props: ParentProps) { return (
-
+
+
+
+
+ x.title} + value={(x) => x.id} + onSelect={navigateToSession} + class="text-14-regular text-text-base max-w-3xs" + variant="ghost" + /> +
+ +
+
+ + Toggle terminal + Ctrl ` +
+ } + > + + +
+
+ +
{ + e.preventDefault() + const startX = e.clientX + const startWidth = layout.sidebar.width() + const maxWidth = window.innerWidth * 0.3 + const minWidth = 150 + const collapseThreshold = 80 + let currentWidth = startWidth + + document.body.style.userSelect = "none" + document.body.style.overflow = "hidden" + + const onMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startX + currentWidth = startWidth + deltaX + const clampedWidth = Math.min(maxWidth, Math.max(minWidth, currentWidth)) + layout.sidebar.resize(clampedWidth) + } + + const onMouseUp = () => { + document.body.style.userSelect = "" + document.body.style.overflow = "" + document.removeEventListener("mousemove", onMouseMove) + document.removeEventListener("mouseup", onMouseUp) + + if (currentWidth < collapseThreshold) { + layout.sidebar.close() + } + } + + document.addEventListener("mousemove", onMouseMove) + document.addEventListener("mouseup", onMouseUp) + }} + /> +
-
{props.children}
+
{props.children}
) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d6ce62b7030..77362533404 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" @@ -31,6 +31,7 @@ import { useSession } from "@/context/session" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Diff } from "@opencode-ai/ui/diff" +import { Terminal } from "@/components/terminal" export default function Page() { const layout = useLayout() @@ -54,6 +55,14 @@ export default function Page() { document.removeEventListener("keydown", handleKeyDown) }) + createEffect(() => { + if (layout.terminal.opened()) { + if (session.terminal.all().length === 0) { + session.terminal.new() + } + } + }) + const handleKeyDown = (event: KeyboardEvent) => { if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { event.preventDefault() @@ -73,6 +82,16 @@ export default function Page() { document.documentElement.setAttribute("data-theme", nextTheme) return } + if (event.ctrlKey && event.key.toLowerCase() === "`") { + event.preventDefault() + layout.terminal.toggle() + return + } + + // @ts-expect-error + if (document.activeElement?.dataset?.component === "terminal") { + return + } const focused = document.activeElement === inputRef if (focused) { @@ -141,7 +160,7 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.opened + const currentTabs = session.layout.tabs.all const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { @@ -259,317 +278,397 @@ export default function Page() { const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) return ( -
- - - - -
- - -
-
Session
- +
+ + + + +
+ + +
+
Session
+ + +
{session.usage.context() ?? 0}%
+
+
+
+ + + } > - -
{session.usage.context() ?? 0}%
- -
- - - - } - > -
- - - -
-
Review
- -
- {session.info()?.summary?.files ?? 0} -
+
+ + +
+
Review
+ +
+ {session.info()?.summary?.files ?? 0} +
+
+
-
- - - - - {(tab) => } - - -
- - setStore("fileSelectOpen", true)} - /> - -
- -
- -
+ + + + + {(tab) => ( + + )} + + +
+ + setStore("fileSelectOpen", true)} + /> + +
+ +
+
- - -
- - 1 - ? "pr-6 pl-18" - : "px-6"), - }} - diffComponent={Diff} - /> -
-
- -
-
New session
-
- -
- {getDirectory(sync.data.path.directory)} - {getFilename(sync.data.path.directory)} -
+
+ + +
+ + 1 + ? "pr-6 pl-18" + : "px-6"), + }} + diffComponent={Diff} + />
-
- -
- Last modified  - - {DateTime.fromMillis(sync.data.project.time.created).toRelative()} - + + +
+
New session
+
+ +
+ {getDirectory(sync.data.path.directory)} + {getFilename(sync.data.path.directory)} +
+
+
+ +
+ Last modified  + + {DateTime.fromMillis(sync.data.project.time.created).toRelative()} + +
+
+ +
+
+ { + inputRef = el + }} + />
- - -
-
- { - inputRef = el +
+
+ +
+ + { + layout.review.tab() + session.layout.setActiveTab("review") + }} + /> + + } />
-
+
- + + +
- { - layout.review.tab() - session.layout.setActiveTab("review") - }} - /> - - } + split />
-
-
- - - + + + + {(tab) => { + const [file] = createResource( + () => tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + + + + {(f) => ( + + )} + + + + ) + }} + + + + + {(draggedFile) => { + const [file] = createResource( + () => draggedFile(), + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( +
+ {(f) => } +
+ ) + }} +
+
+ + +
+ { + inputRef = el + }} + /> +
+
+ + } + > +
    + + {(path) => ( +
  • + +
  • + )} +
    +
+ +
+ + x} + onOpenChange={(open) => setStore("fileSelectOpen", open)} + onSelect={(x) => { + if (x) { + local.file.open(x) + return session.layout.openTab("file://" + x) + } + return undefined + }} + > + {(i) => (
- -
- -
- - {(tab) => { - const [file] = createResource( - () => tab, - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( - - - - {(f) => ( - - )} - - - - ) - }} - - - - - {(draggedFile) => { - const [file] = createResource( - () => draggedFile(), - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( -
- {(f) => } +
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
- ) - }} - - - - -
- { - inputRef = el +
+
+ )} + +
+
+ +
+
{ + e.preventDefault() + const startY = e.clientY + const startHeight = layout.terminal.height() + const maxHeight = window.innerHeight * 0.6 + const minHeight = 100 + const collapseThreshold = 50 + let currentHeight = startHeight + + document.body.style.userSelect = "none" + document.body.style.overflow = "hidden" + + const onMouseMove = (moveEvent: MouseEvent) => { + const deltaY = startY - moveEvent.clientY + currentHeight = startHeight + deltaY + const clampedHeight = Math.min(maxHeight, Math.max(minHeight, currentHeight)) + layout.terminal.resize(clampedHeight) + } + + const onMouseUp = () => { + document.body.style.userSelect = "" + document.body.style.overflow = "" + document.removeEventListener("mousemove", onMouseMove) + document.removeEventListener("mouseup", onMouseUp) + + if (currentHeight < collapseThreshold) { + layout.terminal.close() + } + } + + document.addEventListener("mousemove", onMouseMove) + document.addEventListener("mouseup", onMouseUp) }} /> -
- - - }> -
    - - {(path) => ( -
  • - -
  • + {terminal.title} + + )} +
    +
    + + + +
    + + + {(terminal) => ( + + + )} -
- -
- - x} - onOpenChange={(open) => setStore("fileSelectOpen", open)} - onSelect={(x) => { - if (x) { - local.file.open(x) - return session.layout.openTab("file://" + x) - } - return undefined - }} - > - {(i) => ( -
-
- -
- - {getDirectory(i)} - - {getFilename(i)} -
-
-
-
- )} -
+ +
) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 46c8c3200a6..2c5441998b5 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -72,6 +72,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", "decimal.js": "10.5.0", diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 7754b4a3953..76f78f3faa8 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -5,6 +5,7 @@ import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" +import type { BunWebSocketData } from "hono/bun" await Log.init({ print: process.argv.includes("--print-logs"), @@ -27,7 +28,7 @@ process.on("uncaughtException", (e) => { }) }) -let server: Bun.Server +let server: Bun.Server export const rpc = { async server(input: { port: number; hostname: string }) { if (server) await server.stop(true) @@ -53,7 +54,9 @@ export const rpc = { async shutdown() { Log.Default.info("worker shutting down") await Instance.disposeAll() - await server.stop(true) + // TODO: this should be awaited, but ws connections are + // causing this to hang, need to revisit this + server.stop(true) }, } diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 99eb6c9ff06..ad6e22e1bee 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -8,6 +8,7 @@ export namespace Identifier { permission: "per", user: "usr", part: "prt", + pty: "pty", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts new file mode 100644 index 00000000000..efb519ff2a7 --- /dev/null +++ b/packages/opencode/src/pty/index.ts @@ -0,0 +1,199 @@ +import { spawn, type IPty } from "bun-pty" +import z from "zod" +import { Identifier } from "../id/id" +import { Log } from "../util/log" +import { Bus } from "../bus" +import type { WSContext } from "hono/ws" +import { Instance } from "../project/instance" +import { shell } from "@opencode-ai/util/shell" + +export namespace Pty { + const log = Log.create({ service: "pty" }) + + export const Info = z + .object({ + id: Identifier.schema("pty"), + title: z.string(), + command: z.string(), + args: z.array(z.string()), + cwd: z.string(), + status: z.enum(["running", "exited"]), + pid: z.number(), + }) + .meta({ ref: "Pty" }) + + export type Info = z.infer + + export const CreateInput = z.object({ + command: z.string().optional(), + args: z.array(z.string()).optional(), + cwd: z.string().optional(), + title: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + }) + + export type CreateInput = z.infer + + export const UpdateInput = z.object({ + title: z.string().optional(), + size: z + .object({ + rows: z.number(), + cols: z.number(), + }) + .optional(), + }) + + export type UpdateInput = z.infer + + export const Event = { + Created: Bus.event("pty.created", z.object({ info: Info })), + Updated: Bus.event("pty.updated", z.object({ info: Info })), + Exited: Bus.event("pty.exited", z.object({ id: Identifier.schema("pty"), exitCode: z.number() })), + Deleted: Bus.event("pty.deleted", z.object({ id: Identifier.schema("pty") })), + } + + interface ActiveSession { + info: Info + process: IPty + buffer: string + subscribers: Set + } + + const state = Instance.state( + () => new Map(), + async (sessions) => { + for (const session of sessions.values()) { + try { + session.process.kill() + } catch {} + for (const ws of session.subscribers) { + ws.close() + } + } + sessions.clear() + }, + ) + + export function list() { + return Array.from(state().values()).map((s) => s.info) + } + + export function get(id: string) { + return state().get(id)?.info + } + + export async function create(input: CreateInput) { + const id = Identifier.create("pty", false) + const command = input.command || shell() + const args = input.args || [] + const cwd = input.cwd || Instance.directory + const env = { ...process.env, ...input.env } as Record + log.info("creating session", { id, cmd: command, args, cwd }) + + const ptyProcess = spawn(command, args, { + name: "xterm-256color", + cwd, + env, + }) + const info = { + id, + title: input.title || `Terminal ${id.slice(-4)}`, + command, + args, + cwd, + status: "running", + pid: ptyProcess.pid, + } as const + const session: ActiveSession = { + info, + process: ptyProcess, + buffer: "", + subscribers: new Set(), + } + state().set(id, session) + ptyProcess.onData((data) => { + if (session.subscribers.size === 0) { + session.buffer += data + return + } + for (const ws of session.subscribers) { + if (ws.readyState === 1) { + ws.send(data) + } + } + }) + ptyProcess.onExit(({ exitCode }) => { + log.info("session exited", { id, exitCode }) + session.info.status = "exited" + Bus.publish(Event.Exited, { id, exitCode }) + state().delete(id) + }) + Bus.publish(Event.Created, { info }) + return info + } + + export async function update(id: string, input: UpdateInput) { + const session = state().get(id) + if (!session) return + if (input.title) { + session.info.title = input.title + } + if (input.size) { + session.process.resize(input.size.cols, input.size.rows) + } + Bus.publish(Event.Updated, { info: session.info }) + return session.info + } + + export async function remove(id: string) { + const session = state().get(id) + if (!session) return + log.info("removing session", { id }) + try { + session.process.kill() + } catch {} + for (const ws of session.subscribers) { + ws.close() + } + state().delete(id) + Bus.publish(Event.Deleted, { id }) + } + + export function resize(id: string, cols: number, rows: number) { + const session = state().get(id) + if (session && session.info.status === "running") { + session.process.resize(cols, rows) + } + } + + export function write(id: string, data: string) { + const session = state().get(id) + if (session && session.info.status === "running") { + session.process.write(data) + } + } + + export function connect(id: string, ws: WSContext) { + const session = state().get(id) + if (!session) { + ws.close() + return + } + log.info("client connected to session", { id }) + session.subscribers.add(ws) + if (session.buffer) { + ws.send(session.buffer) + session.buffer = "" + } + return { + onMessage: (message: string | ArrayBuffer) => { + session.process.write(String(message)) + }, + onClose: () => { + log.info("client disconnected from session", { id }) + session.subscribers.delete(ws) + }, + } + } +} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts new file mode 100644 index 00000000000..26e2dfcb121 --- /dev/null +++ b/packages/opencode/src/server/error.ts @@ -0,0 +1,36 @@ +import { resolver } from "hono-openapi" +import z from "zod" +import { Storage } from "../storage/storage" + +export const ERRORS = { + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + z + .object({ + data: z.any(), + errors: z.array(z.record(z.string(), z.any())), + success: z.literal(false), + }) + .meta({ + ref: "BadRequestError", + }), + ), + }, + }, + }, + 404: { + description: "Not found", + content: { + "application/json": { + schema: resolver(Storage.NotFoundError.Schema), + }, + }, + }, +} as const + +export function errors(...codes: number[]) { + return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 31d0822762b..a74b7876f1c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -43,43 +43,13 @@ import { Snapshot } from "@/snapshot" import { SessionSummary } from "@/session/summary" import { GlobalBus } from "@/bus/global" import { SessionStatus } from "@/session/status" +import { upgradeWebSocket, websocket } from "hono/bun" +import { errors } from "./error" +import { Pty } from "@/pty" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false -const ERRORS = { - 400: { - description: "Bad request", - content: { - "application/json": { - schema: resolver( - z - .object({ - data: z.any(), - errors: z.array(z.record(z.string(), z.any())), - success: z.literal(false), - }) - .meta({ - ref: "BadRequestError", - }), - ), - }, - }, - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: resolver(Storage.NotFoundError.Schema), - }, - }, - }, -} as const - -function errors(...codes: number[]) { - return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) -} - export namespace Server { const log = Log.create({ service: "server" }) @@ -192,7 +162,167 @@ export namespace Server { }), ) .use(validator("query", z.object({ directory: z.string().optional() }))) + .route("/project", ProjectRoute) + + .get( + "/pty", + describeRoute({ + description: "List all PTY sessions", + operationId: "pty.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Pty.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(Pty.list()) + }, + ) + .post( + "/pty", + describeRoute({ + description: "Create a new PTY session", + operationId: "pty.create", + responses: { + 200: { + description: "Created session", + content: { + "application/json": { + schema: resolver(Pty.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Pty.CreateInput), + async (c) => { + const info = await Pty.create(c.req.valid("json")) + return c.json(info) + }, + ) + .put( + "/pty/:id", + describeRoute({ + description: "Update PTY session", + operationId: "pty.update", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Pty.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("param", z.object({ id: z.string() })), + validator("json", Pty.UpdateInput), + async (c) => { + const info = await Pty.update(c.req.valid("param").id, c.req.valid("json")) + return c.json(info) + }, + ) + .get( + "/pty/:id", + describeRoute({ + description: "Get PTY session info", + operationId: "pty.get", + responses: { + 200: { + description: "Session info", + content: { + "application/json": { + schema: resolver(Pty.Info), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ id: z.string() })), + async (c) => { + const info = Pty.get(c.req.valid("param").id) + if (!info) { + throw new Storage.NotFoundError({ message: "Session not found" }) + } + return c.json(info) + }, + ) + .delete( + "/pty/:id", + describeRoute({ + description: "Remove a PTY session", + operationId: "pty.remove", + responses: { + 200: { + description: "Session removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ id: z.string() })), + async (c) => { + await Pty.remove(c.req.valid("param").id) + return c.json(true) + }, + ) + .get( + "/pty/:id/connect", + describeRoute({ + description: "Connect to a PTY session", + operationId: "pty.connect", + responses: { + 200: { + description: "Connected session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + 404: { + description: "Session not found", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ id: z.string() })), + upgradeWebSocket((c) => { + const id = c.req.param("id") + let handler: ReturnType + return { + onOpen(_event, ws) { + handler = Pty.connect(id, ws) + }, + onMessage(event) { + handler?.onMessage(String(event.data)) + }, + onClose() { + handler?.onClose() + }, + } + }), + ) + .get( "/config", describeRoute({ @@ -2083,6 +2213,7 @@ export namespace Server { hostname: opts.hostname, idleTimeout: 0, fetch: App().fetch, + websocket: websocket, }) return server } diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 0dc470566ee..d04277cbc81 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -8,6 +8,23 @@ import type { ProjectListResponses, ProjectCurrentData, ProjectCurrentResponses, + PtyListData, + PtyListResponses, + PtyCreateData, + PtyCreateResponses, + PtyCreateErrors, + PtyRemoveData, + PtyRemoveResponses, + PtyRemoveErrors, + PtyGetData, + PtyGetResponses, + PtyGetErrors, + PtyUpdateData, + PtyUpdateResponses, + PtyUpdateErrors, + PtyConnectData, + PtyConnectResponses, + PtyConnectErrors, ConfigGetData, ConfigGetResponses, ConfigUpdateData, @@ -231,6 +248,76 @@ class Project extends _HeyApiClient { } } +class Pty extends _HeyApiClient { + /** + * List all PTY sessions + */ + public list(options?: Options) { + return (options?.client ?? this._client).get({ + url: "/pty", + ...options, + }) + } + + /** + * Create a new PTY session + */ + public create(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/pty", + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }) + } + + /** + * Remove a PTY session + */ + public remove(options: Options) { + return (options.client ?? this._client).delete({ + url: "/pty/{id}", + ...options, + }) + } + + /** + * Get PTY session info + */ + public get(options: Options) { + return (options.client ?? this._client).get({ + url: "/pty/{id}", + ...options, + }) + } + + /** + * Update PTY session + */ + public update(options: Options) { + return (options.client ?? this._client).put({ + url: "/pty/{id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Connect to a PTY session + */ + public connect(options: Options) { + return (options.client ?? this._client).get({ + url: "/pty/{id}/connect", + ...options, + }) + } +} + class Config extends _HeyApiClient { /** * Get config info @@ -1005,6 +1092,7 @@ export class OpencodeClient extends _HeyApiClient { } global = new Global({ client: this._client }) project = new Project({ client: this._client }) + pty = new Pty({ client: this._client }) config = new Config({ client: this._client }) tool = new Tool({ client: this._client }) instance = new Instance({ client: this._client }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 6c80f0b7c52..58ba58d359c 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -655,6 +655,45 @@ export type EventTuiToastShow = { } } +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number +} + +export type EventPtyCreated = { + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated = { + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited = { + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted = { + type: "pty.deleted" + properties: { + id: string + } +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -690,6 +729,10 @@ export type Event = | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted | EventServerConnected export type GlobalEvent = { @@ -708,6 +751,21 @@ export type Project = { } } +export type BadRequestError = { + data: unknown + errors: Array<{ + [key: string]: unknown + }> + success: false +} + +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + /** * Custom keybind configurations */ @@ -1266,14 +1324,6 @@ export type Config = { } } -export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false -} - export type ToolIds = Array export type ToolListItem = { @@ -1295,13 +1345,6 @@ export type VcsInfo = { branch: string } -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type TextPartInput = { id?: string type: "text" @@ -1614,6 +1657,181 @@ export type ProjectCurrentResponses = { export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] +export type PtyListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/pty" +} + +export type PtyListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type PtyListResponse = PtyListResponses[keyof PtyListResponses] + +export type PtyCreateData = { + body?: { + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } + } + path?: never + query?: { + directory?: string + } + url: "/pty" +} + +export type PtyCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors] + +export type PtyCreateResponses = { + /** + * Created session + */ + 200: Pty +} + +export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses] + +export type PtyRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/pty/{id}" +} + +export type PtyRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors] + +export type PtyRemoveResponses = { + /** + * Session removed + */ + 200: boolean +} + +export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses] + +export type PtyGetData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/pty/{id}" +} + +export type PtyGetErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyGetError = PtyGetErrors[keyof PtyGetErrors] + +export type PtyGetResponses = { + /** + * Session info + */ + 200: Pty +} + +export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses] + +export type PtyUpdateData = { + body?: { + title?: string + size?: { + rows: number + cols: number + } + } + path: { + id: string + } + query?: { + directory?: string + } + url: "/pty/{id}" +} + +export type PtyUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors] + +export type PtyUpdateResponses = { + /** + * Updated session + */ + 200: Pty +} + +export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] + +export type PtyConnectData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/pty/{id}/connect" +} + +export type PtyConnectErrors = { + /** + * Session not found + */ + 404: boolean +} + +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] + +export type PtyConnectResponses = { + /** + * Connected session + */ + 200: boolean +} + +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] + export type ConfigGetData = { body?: never path?: never diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 306d7964988..9e4f00a0de7 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -153,10 +153,10 @@ const newIcons = { stop: ``, enter: ``, "layout-left": ``, - "layout-left-partial": ``, + "layout-left-partial": ``, "layout-left-full": ``, "layout-right": ``, - "layout-right-partial": ``, + "layout-right-partial": ``, "layout-right-full": ``, "speech-bubble": ``, "align-right": ``, @@ -167,6 +167,9 @@ const newIcons = { "bubble-5": ``, github: ``, discord: ``, + "layout-bottom": ``, + "layout-bottom-partial": ``, + "layout-bottom-full": ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 421215a78ce..96ddf174cd8 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -20,6 +20,7 @@ [data-component="select-content"] { min-width: 4rem; + max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); border-width: 1px; @@ -39,6 +40,7 @@ } [data-slot="select-select-content-list"] { + min-height: 2rem; overflow-y: auto; max-height: 12rem; white-space: nowrap; diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 464900ef97b..9ba1f177b56 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,10 +1,10 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, type ComponentProps } from "solid-js" +import { createMemo, splitProps, type ComponentProps } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" -export interface SelectProps { +export type SelectProps = Omit>, "value" | "onSelect"> & { placeholder?: string options: T[] current?: T @@ -17,10 +17,21 @@ export interface SelectProps { } export function Select(props: SelectProps & ButtonProps) { + const [local, others] = splitProps(props, [ + "class", + "classList", + "placeholder", + "options", + "current", + "value", + "label", + "groupBy", + "onSelect", + ]) const grouped = createMemo(() => { const result = pipe( - props.options, - groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), + local.options, + groupBy((x) => (local.groupBy ? local.groupBy(x) : "")), // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))), entries(), map(([k, v]) => ({ category: k, options: v })), @@ -29,28 +40,30 @@ export function Select(props: SelectProps & ButtonProps) { }) return ( + // @ts-ignore + {...others} data-component="select" - value={props.current} + value={local.current} options={grouped()} - optionValue={(x) => (props.value ? props.value(x) : (x as string))} - optionTextValue={(x) => (props.label ? props.label(x) : (x as string))} + optionValue={(x) => (local.value ? local.value(x) : (x as string))} + optionTextValue={(x) => (local.label ? local.label(x) : (x as string))} optionGroupChildren="options" - placeholder={props.placeholder} - sectionComponent={(props) => ( - {props.section.rawValue.category} + placeholder={local.placeholder} + sectionComponent={(local) => ( + {local.section.rawValue.category} )} itemComponent={(itemProps) => ( - {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} + {local.label ? local.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} @@ -58,24 +71,25 @@ export function Select(props: SelectProps & ButtonProps) { )} onChange={(v) => { - props.onSelect?.(v ?? undefined) + local.onSelect?.(v ?? undefined) }} > data-slot="select-select-trigger-value"> {(state) => { - const selected = state.selectedOption() ?? props.current - if (!selected) return props.placeholder || "" - if (props.label) return props.label(selected) + const selected = state.selectedOption() ?? local.current + if (!selected) return local.placeholder || "" + if (local.label) return local.label(selected) return selected as string }} @@ -86,8 +100,8 @@ export function Select(props: SelectProps & ButtonProps) { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index d03e57320ff..d60edc5c509 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -6,7 +6,7 @@ background-color: var(--background-stronger); overflow: clip; - [data-slot="tabs-tabs-list"] { + [data-slot="tabs-list"] { height: 48px; width: 100%; position: relative; @@ -36,7 +36,7 @@ } } - [data-slot="tabs-tabs-trigger-wrapper"] { + [data-slot="tabs-trigger-wrapper"] { position: relative; height: 100%; display: flex; @@ -58,14 +58,14 @@ border-right: 1px solid var(--border-weak-base); background-color: var(--background-base); - [data-slot="tabs-tabs-trigger"] { + [data-slot="tabs-trigger"] { display: flex; align-items: center; justify-content: center; padding: 14px 24px; } - [data-slot="tabs-tabs-trigger-close-button"] { + [data-slot="tabs-trigger-close-button"] { display: flex; align-items: center; justify-content: center; @@ -84,12 +84,12 @@ box-shadow: 0 0 0 2px var(--border-focus); } &:has([data-hidden]) { - [data-slot="tabs-tabs-trigger-close-button"] { + [data-slot="tabs-trigger-close-button"] { opacity: 0; } &:hover { - [data-slot="tabs-tabs-trigger-close-button"] { + [data-slot="tabs-trigger-close-button"] { opacity: 1; } } @@ -98,23 +98,23 @@ color: var(--text-strong); background-color: transparent; border-bottom-color: transparent; - [data-slot="tabs-tabs-trigger-close-button"] { + [data-slot="tabs-trigger-close-button"] { opacity: 1; } } &:hover:not(:disabled):not([data-selected]) { color: var(--text-strong); } - &:has([data-slot="tabs-tabs-trigger-close-button"]) { + &:has([data-slot="tabs-trigger-close-button"]) { padding-right: 12px; - [data-slot="tabs-tabs-trigger"] { + [data-slot="tabs-trigger"] { padding-right: 0; } } } - [data-slot="tabs-tabs-content"] { + [data-slot="tabs-content"] { overflow-y: auto; flex: 1; @@ -129,4 +129,80 @@ outline: none; } } + + &[data-variant="alt"] { + [data-slot="tabs-list"] { + padding-left: 24px; + padding-right: 24px; + gap: 12px; + border-bottom: 1px solid var(--border-weak-base); + background-color: transparent; + + &::after { + border: none; + background-color: transparent; + } + &:empty::after { + display: none; + } + } + + [data-slot="tabs-trigger-wrapper"] { + border: none; + color: var(--text-base); + background-color: transparent; + border-bottom-width: 2px; + border-bottom-style: solid; + border-bottom-color: transparent; + gap: 4px; + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-x-large); /* 171.429% */ + letter-spacing: var(--letter-spacing-normal); + + [data-slot="tabs-trigger"] { + height: 100%; + padding: 4px; + background-color: transparent; + border-bottom-width: 2px; + border-bottom-color: transparent; + } + + [data-slot="tabs-trigger-close-button"] { + display: flex; + align-items: center; + justify-content: center; + } + + [data-component="icon-button"] { + width: 16px; + height: 16px; + margin: 0; + } + + &:has([data-selected]) { + color: var(--text-strong); + background-color: transparent; + border-bottom-color: var(--icon-strong-base); + } + + &:hover:not(:disabled):not([data-selected]) { + color: var(--text-strong); + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-right: 0; + [data-slot="tabs-trigger"] { + padding-right: 0; + } + } + } + + /* [data-slot="tabs-content"] { */ + /* } */ + } } diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 68acd88d4e1..d91ad3c4156 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -2,7 +2,9 @@ import { Tabs as Kobalte } from "@kobalte/core/tabs" import { Show, splitProps, type JSX } from "solid-js" import type { ComponentProps, ParentProps } from "solid-js" -export interface TabsProps extends ComponentProps {} +export interface TabsProps extends ComponentProps { + variant?: "normal" | "alt" +} export interface TabsListProps extends ComponentProps {} export interface TabsTriggerProps extends ComponentProps { classes?: { @@ -14,11 +16,12 @@ export interface TabsTriggerProps extends ComponentProps export interface TabsContentProps extends ComponentProps {} function TabsRoot(props: TabsProps) { - const [split, rest] = splitProps(props, ["class", "classList"]) + const [split, rest] = splitProps(props, ["class", "classList", "variant"]) return ( ) { ]) return (
) { > {split.children} {(closeButton) => ( -
+
{closeButton()}
)} @@ -81,7 +84,7 @@ function TabsContent(props: ParentProps) { return ( Date: Thu, 4 Dec 2025 15:57:01 -0600 Subject: [PATCH 21/23] Revert "feat(desktop): terminal pane (#5081)" This reverts commit d763c11a6d5bc57869f11c87f5a293f61e427e0a. --- bun.lock | 13 +- flake.lock | 6 +- nix/hashes.json | 2 +- package.json | 2 +- packages/desktop/package.json | 2 - packages/desktop/src/addons/serialize.ts | 649 ------------------- packages/desktop/src/components/terminal.tsx | 151 ----- packages/desktop/src/context/layout.tsx | 22 +- packages/desktop/src/context/sdk.tsx | 2 +- packages/desktop/src/context/session.tsx | 100 +-- packages/desktop/src/pages/layout.tsx | 114 +--- packages/desktop/src/pages/session.tsx | 649 ++++++++----------- packages/opencode/package.json | 1 - packages/opencode/src/cli/cmd/tui/worker.ts | 7 +- packages/opencode/src/id/id.ts | 1 - packages/opencode/src/pty/index.ts | 199 ------ packages/opencode/src/server/error.ts | 36 - packages/opencode/src/server/server.ts | 197 +----- packages/sdk/js/src/gen/sdk.gen.ts | 88 --- packages/sdk/js/src/gen/types.gen.ts | 248 +------ packages/ui/src/components/icon.tsx | 7 +- packages/ui/src/components/select.css | 2 - packages/ui/src/components/select.tsx | 56 +- packages/ui/src/components/tabs.css | 96 +-- packages/ui/src/components/tabs.tsx | 17 +- packages/ui/src/components/tooltip.css | 7 +- packages/util/src/shell.ts | 13 - 27 files changed, 395 insertions(+), 2292 deletions(-) delete mode 100644 packages/desktop/src/addons/serialize.ts delete mode 100644 packages/desktop/src/components/terminal.tsx delete mode 100644 packages/opencode/src/pty/index.ts delete mode 100644 packages/opencode/src/server/error.ts delete mode 100644 packages/util/src/shell.ts diff --git a/bun.lock b/bun.lock index ff4f1444705..aad651621cb 100644 --- a/bun.lock +++ b/bun.lock @@ -135,13 +135,11 @@ "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", - "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "0.3.0", "luxon": "catalog:", "marked": "16.2.0", "marked-shiki": "1.2.1", @@ -248,7 +246,6 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", - "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", "decimal.js": "10.5.0", @@ -460,7 +457,7 @@ "ai": "5.0.97", "diff": "8.0.2", "fuzzysort": "3.1.0", - "hono": "4.10.7", + "hono": "4.7.10", "hono-openapi": "1.1.1", "luxon": "3.6.1", "remeda": "2.26.0", @@ -1509,8 +1506,6 @@ "@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="], - "@solid-primitives/websocket": ["@solid-primitives/websocket@1.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-F06tA2FKa5VsnS4E4WEc3jHpsJfXRlMTGOtolugTzCqV3JmJTyvk9UVg1oz6PgGHKGi1CQ91OP8iW34myyJgaQ=="], - "@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="], "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], @@ -1895,8 +1890,6 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="], - "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], @@ -2341,8 +2334,6 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], - "ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="], - "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], "giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], @@ -2437,7 +2428,7 @@ "hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="], - "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], + "hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="], "hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="], diff --git a/flake.lock b/flake.lock index ca9fd5f8f30..4e7cf41e1b7 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1764794580, - "narHash": "sha256-UMVihg0OQ980YqmOAPz+zkuCEb9hpE5Xj2v+ZGNjQ+M=", + "lastModified": 1764733908, + "narHash": "sha256-QJiih52NU+nm7XQWCj+K8SwUdIEayDQ1FQgjkYISt4I=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ebc94f855ef25347c314258c10393a92794e7ab9", + "rev": "cadcc8de247676e4751c9d4a935acb2c0b059113", "type": "github" }, "original": { diff --git a/nix/hashes.json b/nix/hashes.json index 6bc2eaec159..47634e2ed82 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-Wrfwnmo0lpck2rbt6ttkAuDGvBvqqWJfNA8QDQxoZ6I=" + "nodeModules": "sha256-ZGKC7h4ScHDzVYj8qb1lN/weZhyZivPS8kpNAZvgO0I=" } diff --git a/package.json b/package.json index a962be92605..a5e7c14621b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "ai": "5.0.97", - "hono": "4.10.7", + "hono": "4.7.10", "hono-openapi": "1.1.1", "fuzzysort": "3.1.0", "luxon": "3.6.1", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index addff88b046..f2f8768cbb8 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -33,13 +33,11 @@ "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", - "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "0.3.0", "luxon": "catalog:", "marked": "16.2.0", "marked-shiki": "1.2.1", diff --git a/packages/desktop/src/addons/serialize.ts b/packages/desktop/src/addons/serialize.ts deleted file mode 100644 index 03899ff109b..00000000000 --- a/packages/desktop/src/addons/serialize.ts +++ /dev/null @@ -1,649 +0,0 @@ -/** - * SerializeAddon - Serialize terminal buffer contents - * - * Port of xterm.js addon-serialize for ghostty-web. - * Enables serialization of terminal contents to a string that can - * be written back to restore terminal state. - * - * Usage: - * ```typescript - * const serializeAddon = new SerializeAddon(); - * term.loadAddon(serializeAddon); - * const content = serializeAddon.serialize(); - * ``` - */ - -import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web" - -// ============================================================================ -// Buffer Types (matching ghostty-web internal interfaces) -// ============================================================================ - -interface IBuffer { - readonly type: "normal" | "alternate" - readonly cursorX: number - readonly cursorY: number - readonly viewportY: number - readonly baseY: number - readonly length: number - getLine(y: number): IBufferLine | undefined - getNullCell(): IBufferCell -} - -interface IBufferLine { - readonly length: number - readonly isWrapped: boolean - getCell(x: number): IBufferCell | undefined - translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string -} - -interface IBufferCell { - getChars(): string - getCode(): number - getWidth(): number - getFgColorMode(): number - getBgColorMode(): number - getFgColor(): number - getBgColor(): number - isBold(): number - isItalic(): number - isUnderline(): number - isStrikethrough(): number - isBlink(): number - isInverse(): number - isInvisible(): number - isFaint(): number - isDim(): boolean -} - -// ============================================================================ -// Types -// ============================================================================ - -export interface ISerializeOptions { - /** - * The row range to serialize. When an explicit range is specified, the cursor - * will get its final repositioning. - */ - range?: ISerializeRange - /** - * The number of rows in the scrollback buffer to serialize, starting from - * the bottom of the scrollback buffer. When not specified, all available - * rows in the scrollback buffer will be serialized. - */ - scrollback?: number - /** - * Whether to exclude the terminal modes from the serialization. - * Default: false - */ - excludeModes?: boolean - /** - * Whether to exclude the alt buffer from the serialization. - * Default: false - */ - excludeAltBuffer?: boolean -} - -export interface ISerializeRange { - /** - * The line to start serializing (inclusive). - */ - start: number - /** - * The line to end serializing (inclusive). - */ - end: number -} - -export interface IHTMLSerializeOptions { - /** - * The number of rows in the scrollback buffer to serialize, starting from - * the bottom of the scrollback buffer. - */ - scrollback?: number - /** - * Whether to only serialize the selection. - * Default: false - */ - onlySelection?: boolean - /** - * Whether to include the global background of the terminal. - * Default: false - */ - includeGlobalBackground?: boolean - /** - * The range to serialize. This is prioritized over onlySelection. - */ - range?: { - startLine: number - endLine: number - startCol: number - } -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function constrain(value: number, low: number, high: number): number { - return Math.max(low, Math.min(value, high)) -} - -function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean { - return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor() -} - -function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean { - return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor() -} - -function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean { - return ( - !!cell1.isInverse() === !!cell2.isInverse() && - !!cell1.isBold() === !!cell2.isBold() && - !!cell1.isUnderline() === !!cell2.isUnderline() && - !!cell1.isBlink() === !!cell2.isBlink() && - !!cell1.isInvisible() === !!cell2.isInvisible() && - !!cell1.isItalic() === !!cell2.isItalic() && - !!cell1.isDim() === !!cell2.isDim() && - !!cell1.isStrikethrough() === !!cell2.isStrikethrough() - ) -} - -// ============================================================================ -// Base Serialize Handler -// ============================================================================ - -abstract class BaseSerializeHandler { - constructor(protected readonly _buffer: IBuffer) {} - - public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string { - let oldCell = this._buffer.getNullCell() - - const startRow = range.start.y - const endRow = range.end.y - const startColumn = range.start.x - const endColumn = range.end.x - - this._beforeSerialize(endRow - startRow, startRow, endRow) - - for (let row = startRow; row <= endRow; row++) { - const line = this._buffer.getLine(row) - if (line) { - const startLineColumn = row === range.start.y ? startColumn : 0 - const endLineColumn = row === range.end.y ? endColumn : line.length - for (let col = startLineColumn; col < endLineColumn; col++) { - const c = line.getCell(col) - if (!c) { - continue - } - this._nextCell(c, oldCell, row, col) - oldCell = c - } - } - this._rowEnd(row, row === endRow) - } - - this._afterSerialize() - - return this._serializeString(excludeFinalCursorPosition) - } - - protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {} - protected _rowEnd(_row: number, _isLastRow: boolean): void {} - protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {} - protected _afterSerialize(): void {} - protected _serializeString(_excludeFinalCursorPosition?: boolean): string { - return "" - } -} - -// ============================================================================ -// String Serialize Handler -// ============================================================================ - -class StringSerializeHandler extends BaseSerializeHandler { - private _rowIndex: number = 0 - private _allRows: string[] = [] - private _allRowSeparators: string[] = [] - private _currentRow: string = "" - private _nullCellCount: number = 0 - private _cursorStyle: IBufferCell - private _cursorStyleRow: number = 0 - private _cursorStyleCol: number = 0 - private _backgroundCell: IBufferCell - private _firstRow: number = 0 - private _lastCursorRow: number = 0 - private _lastCursorCol: number = 0 - private _lastContentCursorRow: number = 0 - private _lastContentCursorCol: number = 0 - private _thisRowLastChar: IBufferCell - private _thisRowLastSecondChar: IBufferCell - private _nextRowFirstChar: IBufferCell - - constructor( - buffer: IBuffer, - private readonly _terminal: ITerminalCore, - ) { - super(buffer) - this._cursorStyle = this._buffer.getNullCell() - this._backgroundCell = this._buffer.getNullCell() - this._thisRowLastChar = this._buffer.getNullCell() - this._thisRowLastSecondChar = this._buffer.getNullCell() - this._nextRowFirstChar = this._buffer.getNullCell() - } - - protected _beforeSerialize(rows: number, start: number, _end: number): void { - this._allRows = new Array(rows) - this._lastContentCursorRow = start - this._lastCursorRow = start - this._firstRow = start - } - - protected _rowEnd(row: number, isLastRow: boolean): void { - // if there is colorful empty cell at line end, we must pad it back - if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) { - this._currentRow += `\u001b[${this._nullCellCount}X` - } - - let rowSeparator = "" - - if (!isLastRow) { - // Enable BCE - if (row - this._firstRow >= this._terminal.rows) { - const line = this._buffer.getLine(this._cursorStyleRow) - const cell = line?.getCell(this._cursorStyleCol) - if (cell) { - this._backgroundCell = cell - } - } - - const currentLine = this._buffer.getLine(row)! - const nextLine = this._buffer.getLine(row + 1)! - - if (!nextLine.isWrapped) { - rowSeparator = "\r\n" - this._lastCursorRow = row + 1 - this._lastCursorCol = 0 - } else { - rowSeparator = "" - const thisRowLastChar = currentLine.getCell(currentLine.length - 1) - const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2) - const nextRowFirstChar = nextLine.getCell(0) - - if (thisRowLastChar) this._thisRowLastChar = thisRowLastChar - if (thisRowLastSecondChar) this._thisRowLastSecondChar = thisRowLastSecondChar - if (nextRowFirstChar) this._nextRowFirstChar = nextRowFirstChar - - const isNextRowFirstCharDoubleWidth = this._nextRowFirstChar.getWidth() > 1 - - let isValid = false - - if ( - this._nextRowFirstChar.getChars() && - (isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0) - ) { - if ( - (this._thisRowLastChar.getChars() || this._thisRowLastChar.getWidth() === 0) && - equalBg(this._thisRowLastChar, this._nextRowFirstChar) - ) { - isValid = true - } - - if ( - isNextRowFirstCharDoubleWidth && - (this._thisRowLastSecondChar.getChars() || this._thisRowLastSecondChar.getWidth() === 0) && - equalBg(this._thisRowLastChar, this._nextRowFirstChar) && - equalBg(this._thisRowLastSecondChar, this._nextRowFirstChar) - ) { - isValid = true - } - } - - if (!isValid) { - rowSeparator = "-".repeat(this._nullCellCount + 1) - rowSeparator += "\u001b[1D\u001b[1X" - - if (this._nullCellCount > 0) { - rowSeparator += "\u001b[A" - rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C` - rowSeparator += `\u001b[${this._nullCellCount}X` - rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D` - rowSeparator += "\u001b[B" - } - - this._lastContentCursorRow = row + 1 - this._lastContentCursorCol = 0 - this._lastCursorRow = row + 1 - this._lastCursorCol = 0 - } - } - } - - this._allRows[this._rowIndex] = this._currentRow - this._allRowSeparators[this._rowIndex++] = rowSeparator - this._currentRow = "" - this._nullCellCount = 0 - } - - private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] { - const sgrSeq: number[] = [] - const fgChanged = !equalFg(cell, oldCell) - const bgChanged = !equalBg(cell, oldCell) - const flagsChanged = !equalFlags(cell, oldCell) - - if (fgChanged || bgChanged || flagsChanged) { - if (this._isAttributeDefault(cell)) { - if (!this._isAttributeDefault(oldCell)) { - sgrSeq.push(0) - } - } else { - if (fgChanged) { - const color = cell.getFgColor() - const mode = cell.getFgColorMode() - if (mode === 2) { - // RGB - sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) - } else if (mode === 1) { - // Palette - if (color >= 16) { - sgrSeq.push(38, 5, color) - } else { - sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)) - } - } else { - sgrSeq.push(39) - } - } - if (bgChanged) { - const color = cell.getBgColor() - const mode = cell.getBgColorMode() - if (mode === 2) { - // RGB - sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff) - } else if (mode === 1) { - // Palette - if (color >= 16) { - sgrSeq.push(48, 5, color) - } else { - sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)) - } - } else { - sgrSeq.push(49) - } - } - if (flagsChanged) { - if (!!cell.isInverse() !== !!oldCell.isInverse()) { - sgrSeq.push(cell.isInverse() ? 7 : 27) - } - if (!!cell.isBold() !== !!oldCell.isBold()) { - sgrSeq.push(cell.isBold() ? 1 : 22) - } - if (!!cell.isUnderline() !== !!oldCell.isUnderline()) { - sgrSeq.push(cell.isUnderline() ? 4 : 24) - } - if (!!cell.isBlink() !== !!oldCell.isBlink()) { - sgrSeq.push(cell.isBlink() ? 5 : 25) - } - if (!!cell.isInvisible() !== !!oldCell.isInvisible()) { - sgrSeq.push(cell.isInvisible() ? 8 : 28) - } - if (!!cell.isItalic() !== !!oldCell.isItalic()) { - sgrSeq.push(cell.isItalic() ? 3 : 23) - } - if (!!cell.isDim() !== !!oldCell.isDim()) { - sgrSeq.push(cell.isDim() ? 2 : 22) - } - if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) { - sgrSeq.push(cell.isStrikethrough() ? 9 : 29) - } - } - } - } - - return sgrSeq - } - - private _isAttributeDefault(cell: IBufferCell): boolean { - return ( - cell.getFgColorMode() === 0 && - cell.getBgColorMode() === 0 && - !cell.isBold() && - !cell.isItalic() && - !cell.isUnderline() && - !cell.isBlink() && - !cell.isInverse() && - !cell.isInvisible() && - !cell.isDim() && - !cell.isStrikethrough() - ) - } - - protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void { - const isPlaceHolderCell = cell.getWidth() === 0 - - if (isPlaceHolderCell) { - return - } - - const isEmptyCell = cell.getChars() === "" - - const sgrSeq = this._diffStyle(cell, this._cursorStyle) - - const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0 - - if (styleChanged) { - if (this._nullCellCount > 0) { - if (!equalBg(this._cursorStyle, this._backgroundCell)) { - this._currentRow += `\u001b[${this._nullCellCount}X` - } - this._currentRow += `\u001b[${this._nullCellCount}C` - this._nullCellCount = 0 - } - - this._lastContentCursorRow = this._lastCursorRow = row - this._lastContentCursorCol = this._lastCursorCol = col - - this._currentRow += `\u001b[${sgrSeq.join(";")}m` - - const line = this._buffer.getLine(row) - const cellFromLine = line?.getCell(col) - if (cellFromLine) { - this._cursorStyle = cellFromLine - this._cursorStyleRow = row - this._cursorStyleCol = col - } - } - - if (isEmptyCell) { - this._nullCellCount += cell.getWidth() - } else { - if (this._nullCellCount > 0) { - if (equalBg(this._cursorStyle, this._backgroundCell)) { - this._currentRow += `\u001b[${this._nullCellCount}C` - } else { - this._currentRow += `\u001b[${this._nullCellCount}X` - this._currentRow += `\u001b[${this._nullCellCount}C` - } - this._nullCellCount = 0 - } - - this._currentRow += cell.getChars() - - this._lastContentCursorRow = this._lastCursorRow = row - this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth() - } - } - - protected _serializeString(excludeFinalCursorPosition?: boolean): string { - let rowEnd = this._allRows.length - - if (this._buffer.length - this._firstRow <= this._terminal.rows) { - rowEnd = this._lastContentCursorRow + 1 - this._firstRow - this._lastCursorCol = this._lastContentCursorCol - this._lastCursorRow = this._lastContentCursorRow - } - - let content = "" - - for (let i = 0; i < rowEnd; i++) { - content += this._allRows[i] - if (i + 1 < rowEnd) { - content += this._allRowSeparators[i] - } - } - - if (!excludeFinalCursorPosition) { - // Get cursor position relative to viewport (1-indexed for ANSI) - // cursorY is relative to the viewport, cursorX is column position - const cursorRow = this._buffer.cursorY + 1 // 1-indexed - const cursorCol = this._buffer.cursorX + 1 // 1-indexed - - // Use absolute cursor positioning (CUP - Cursor Position) - // This is more reliable than relative moves which depend on knowing - // exactly where the cursor ended up after all the content - content += `\u001b[${cursorRow};${cursorCol}H` - } - - return content - } -} - -// ============================================================================ -// SerializeAddon Class -// ============================================================================ - -export class SerializeAddon implements ITerminalAddon { - private _terminal?: ITerminalCore - - /** - * Activate the addon (called by Terminal.loadAddon) - */ - public activate(terminal: ITerminalCore): void { - this._terminal = terminal - } - - /** - * Dispose the addon and clean up resources - */ - public dispose(): void { - this._terminal = undefined - } - - /** - * Serializes terminal rows into a string that can be written back to the - * terminal to restore the state. The cursor will also be positioned to the - * correct cell. - * - * @param options Custom options to allow control over what gets serialized. - */ - public serialize(options?: ISerializeOptions): string { - if (!this._terminal) { - throw new Error("Cannot use addon until it has been loaded") - } - - const terminal = this._terminal as any - const buffer = terminal.buffer - - if (!buffer) { - return "" - } - - const activeBuffer = buffer.active || buffer.normal - if (!activeBuffer) { - return "" - } - - let content = options?.range - ? this._serializeBufferByRange(activeBuffer, options.range, true) - : this._serializeBufferByScrollback(activeBuffer, options?.scrollback) - - // Handle alternate buffer if active and not excluded - if (!options?.excludeAltBuffer) { - const altBuffer = buffer.alternate - if (altBuffer && buffer.active?.type === "alternate") { - const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined) - content += `\u001b[?1049h\u001b[H${alternateContent}` - } - } - - return content - } - - /** - * Serializes terminal content as plain text (no escape sequences) - * @param options Custom options to allow control over what gets serialized. - */ - public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string { - if (!this._terminal) { - throw new Error("Cannot use addon until it has been loaded") - } - - const terminal = this._terminal as any - const buffer = terminal.buffer - - if (!buffer) { - return "" - } - - const activeBuffer = buffer.active || buffer.normal - if (!activeBuffer) { - return "" - } - - const maxRows = activeBuffer.length - const scrollback = options?.scrollback - const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows) - - const startRow = maxRows - correctRows - const endRow = maxRows - 1 - const lines: string[] = [] - - for (let row = startRow; row <= endRow; row++) { - const line = activeBuffer.getLine(row) - if (line) { - const text = line.translateToString(options?.trimWhitespace ?? true) - lines.push(text) - } - } - - // Trim trailing empty lines if requested - if (options?.trimWhitespace) { - while (lines.length > 0 && lines[lines.length - 1] === "") { - lines.pop() - } - } - - return lines.join("\n") - } - - private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string { - const maxRows = buffer.length - const rows = this._terminal?.rows ?? 24 - const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows) - return this._serializeBufferByRange( - buffer, - { - start: maxRows - correctRows, - end: maxRows - 1, - }, - false, - ) - } - - private _serializeBufferByRange( - buffer: IBuffer, - range: ISerializeRange, - excludeFinalCursorPosition: boolean, - ): string { - const handler = new StringSerializeHandler(buffer, this._terminal!) - const cols = this._terminal?.cols ?? 80 - return handler.serialize( - { - start: { x: 0, y: range.start }, - end: { x: cols, y: range.end }, - }, - excludeFinalCursorPosition, - ) - } -} diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx deleted file mode 100644 index 49a45a432bc..00000000000 --- a/packages/desktop/src/components/terminal.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { init, Terminal as Term, FitAddon } from "ghostty-web" -import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" -import { createReconnectingWS, ReconnectingWebSocket } from "@solid-primitives/websocket" -import { useSDK } from "@/context/sdk" -import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/session" - -await init() - -export interface TerminalProps extends ComponentProps<"div"> { - pty: LocalPTY - onSubmit?: () => void - onCleanup?: (pty: LocalPTY) => void -} - -export const Terminal = (props: TerminalProps) => { - const sdk = useSDK() - let container!: HTMLDivElement - const [local, others] = splitProps(props, ["pty", "class", "classList"]) - let ws: ReconnectingWebSocket - let term: Term - let serializeAddon: SerializeAddon - let fitAddon: FitAddon - - onMount(async () => { - ws = createReconnectingWS(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) - term = new Term({ - cursorBlink: true, - fontSize: 14, - fontFamily: "TX-02, monospace", - allowTransparency: true, - theme: { - background: "#191515", - foreground: "#d4d4d4", - }, - scrollback: 10_000, - }) - term.attachCustomKeyEventHandler((event) => { - // allow for ctrl-` to toggle terminal in parent - if (event.ctrlKey && event.key.toLowerCase() === "`") { - event.preventDefault() - return true - } - return false - }) - - fitAddon = new FitAddon() - serializeAddon = new SerializeAddon() - term.loadAddon(serializeAddon) - term.loadAddon(fitAddon) - - term.open(container) - - if (local.pty.buffer) { - const originalSize = { cols: term.cols, rows: term.rows } - let resized = false - if (local.pty.rows && local.pty.cols) { - term.resize(local.pty.cols, local.pty.rows) - resized = true - } - term.write(local.pty.buffer) - if (local.pty.scrollY) { - term.scrollToLine(local.pty.scrollY) - } - if (resized) { - term.resize(originalSize.cols, originalSize.rows) - } - } - - container.focus() - - fitAddon.fit() - fitAddon.observeResize() - window.addEventListener("resize", () => fitAddon.fit()) - term.onResize(async (size) => { - if (ws && ws.readyState === WebSocket.OPEN) { - await sdk.client.pty.update({ - path: { id: local.pty.id }, - body: { - size: { - cols: size.cols, - rows: size.rows, - }, - }, - }) - } - }) - term.onData((data) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(data) - } - }) - term.onKey((key) => { - if (key.key == "Enter") { - props.onSubmit?.() - } - }) - // term.onScroll((ydisp) => { - // console.log("Scroll position:", ydisp) - // }) - ws.addEventListener("open", () => { - console.log("WebSocket connected") - sdk.client.pty.update({ - path: { id: local.pty.id }, - body: { - size: { - cols: term.cols, - rows: term.rows, - }, - }, - }) - }) - ws.addEventListener("message", (event) => { - term.write(event.data) - }) - ws.addEventListener("error", (error) => { - console.error("WebSocket error:", error) - }) - ws.addEventListener("close", () => { - console.log("WebSocket disconnected") - }) - }) - - onCleanup(() => { - if (serializeAddon && props.onCleanup) { - const buffer = serializeAddon.serialize() - props.onCleanup({ - ...local.pty, - buffer, - rows: term.rows, - cols: term.cols, - scrollY: term.getViewportY(), - }) - } - ws?.close() - term?.dispose() - }) - - return ( -
- ) -} diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index ca736e84e6b..81e8b537abc 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -15,16 +15,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( opened: true, width: 280, }, - terminal: { - opened: false, - height: 280, - }, review: { state: "pane" as "pane" | "tab", }, }), { - name: "____default-layout", + name: "___default-layout", }, ) @@ -65,22 +61,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("sidebar", "width", width) }, }, - terminal: { - opened: createMemo(() => store.terminal.opened), - open() { - setStore("terminal", "opened", true) - }, - close() { - setStore("terminal", "opened", false) - }, - toggle() { - setStore("terminal", "opened", (x) => !x) - }, - height: createMemo(() => store.terminal.height), - resize(height: number) { - setStore("terminal", "height", height) - }, - }, review: { state: createMemo(() => store.review?.state ?? "closed"), pane() { diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx index 144202ee209..81b32035a0b 100644 --- a/packages/desktop/src/context/sdk.tsx +++ b/packages/desktop/src/context/sdk.tsx @@ -27,6 +27,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ abort.abort() }) - return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url } + return { directory: props.directory, client: sdk, event: emitter } }, }) diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx index 4e9fe71f8a7..72098a93951 100644 --- a/packages/desktop/src/context/session.tsx +++ b/packages/desktop/src/context/session.tsx @@ -8,25 +8,14 @@ import { pipe, sumBy } from "remeda" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk" import { useParams } from "@solidjs/router" import { base64Encode } from "@/utils" -import { useSDK } from "./sdk" - -export type LocalPTY = { - id: string - title: string - rows?: number - cols?: number - buffer?: string - scrollY?: number -} export const { use: useSession, provider: SessionProvider } = createSimpleContext({ name: "Session", init: () => { - const sdk = useSDK() const params = useParams() const sync = useSync() const name = createMemo( - () => `______${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`, + () => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`, ) const [store, setStore] = makePersisted( @@ -34,21 +23,16 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex messageId?: string tabs: { active?: string - all: string[] + opened: string[] } prompt: Prompt cursor?: number - terminals: { - active?: string - all: LocalPTY[] - } }>({ tabs: { - all: [], + opened: [], }, prompt: clonePrompt(DEFAULT_PROMPT), cursor: undefined, - terminals: { all: [] }, }), { name: name(), @@ -154,7 +138,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex setStore("tabs", "active", tab) }, setOpenedTabs(tabs: string[]) { - setStore("tabs", "all", tabs) + setStore("tabs", "opened", tabs) }, async openTab(tab: string) { if (tab === "chat") { @@ -162,8 +146,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex return } if (tab !== "review") { - if (!store.tabs.all.includes(tab)) { - setStore("tabs", "all", [...store.tabs.all, tab]) + if (!store.tabs.opened.includes(tab)) { + setStore("tabs", "opened", [...store.tabs.opened, tab]) } } setStore("tabs", "active", tab) @@ -172,88 +156,28 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex batch(() => { setStore( "tabs", - "all", - store.tabs.all.filter((x) => x !== tab), + "opened", + store.tabs.opened.filter((x) => x !== tab), ) if (store.tabs.active === tab) { - const index = store.tabs.all.findIndex((f) => f === tab) - const previous = store.tabs.all[Math.max(0, index - 1)] + const index = store.tabs.opened.findIndex((f) => f === tab) + const previous = store.tabs.opened[Math.max(0, index - 1)] setStore("tabs", "active", previous) } }) }, moveTab(tab: string, to: number) { - const index = store.tabs.all.findIndex((f) => f === tab) + const index = store.tabs.opened.findIndex((f) => f === tab) if (index === -1) return setStore( "tabs", - "all", + "opened", produce((opened) => { opened.splice(to, 0, opened.splice(index, 1)[0]) }), ) }, }, - terminal: { - all: createMemo(() => Object.values(store.terminals.all)), - active: createMemo(() => store.terminals.active), - new() { - sdk.client.pty.create({ body: { title: `Terminal ${store.terminals.all.length + 1}` } }).then((pty) => { - const id = pty.data?.id - if (!id) return - batch(() => { - setStore("terminals", "all", [ - ...store.terminals.all, - { - id, - title: pty.data?.title ?? "Terminal", - // rows: pty.data?.rows ?? 24, - // cols: pty.data?.cols ?? 80, - // buffer: "", - // scrollY: 0, - }, - ]) - setStore("terminals", "active", id) - }) - }) - }, - update(pty: Partial & { id: string }) { - setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - path: { id: pty.id }, - body: { title: pty.title, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined }, - }) - }, - open(id: string) { - setStore("terminals", "active", id) - }, - async close(id: string) { - batch(() => { - setStore( - "terminals", - "all", - store.terminals.all.filter((x) => x.id !== id), - ) - if (store.terminals.active === id) { - const index = store.terminals.all.findIndex((f) => f.id === id) - const previous = store.tabs.all[Math.max(0, index - 1)] - setStore("terminals", "active", previous) - } - }) - await sdk.client.pty.remove({ path: { id } }) - }, - move(id: string, to: number) { - const index = store.terminals.all.findIndex((f) => f.id === id) - if (index === -1) return - setStore( - "terminals", - "all", - produce((all) => { - all.splice(to, 0, all.splice(index, 1)[0]) - }), - ) - }, - }, } }, }) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 106a2e733fb..15180c88566 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,9 +1,9 @@ import { createMemo, For, ParentProps, Show } from "solid-js" import { DateTime } from "luxon" -import { A, useNavigate, useParams } from "@solidjs/router" +import { A, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" -import { base64Decode, base64Encode } from "@/utils" +import { base64Encode } from "@/utils" import { Mark } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -12,21 +12,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" -import { Select } from "@opencode-ai/ui/select" -import { Session } from "@opencode-ai/sdk/client" export default function Layout(props: ParentProps) { - const navigate = useNavigate() const params = useParams() const globalSync = useGlobalSync() const layout = useLayout() - const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) - const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) - const currentSession = createMemo(() => sessions().find((s) => s.id === params.id) ?? sessions().at(0)) - - function navigateToSession(session: Session | undefined) { - navigate(`/${params.dir}/session/${session?.id}`) - } const handleOpenProject = async () => { // layout.projects.open(dir.) @@ -34,7 +24,7 @@ export default function Layout(props: ParentProps) { return (
-
+
-
-
-
- x.title} - value={(x) => x.id} - onSelect={navigateToSession} - class="text-14-regular text-text-base max-w-3xs" - variant="ghost" - /> -
- -
-
- - Toggle terminal - Ctrl ` -
- } - > - - -
-
- -
{ - e.preventDefault() - const startX = e.clientX - const startWidth = layout.sidebar.width() - const maxWidth = window.innerWidth * 0.3 - const minWidth = 150 - const collapseThreshold = 80 - let currentWidth = startWidth - - document.body.style.userSelect = "none" - document.body.style.overflow = "hidden" - - const onMouseMove = (moveEvent: MouseEvent) => { - const deltaX = moveEvent.clientX - startX - currentWidth = startWidth + deltaX - const clampedWidth = Math.min(maxWidth, Math.max(minWidth, currentWidth)) - layout.sidebar.resize(clampedWidth) - } - - const onMouseUp = () => { - document.body.style.userSelect = "" - document.body.style.overflow = "" - document.removeEventListener("mousemove", onMouseMove) - document.removeEventListener("mouseup", onMouseUp) - - if (currentWidth < collapseThreshold) { - layout.sidebar.close() - } - } - - document.addEventListener("mousemove", onMouseMove) - document.addEventListener("mouseup", onMouseUp) - }} - /> -
-
{props.children}
+
{props.children}
) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 77362533404..d6ce62b7030 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" @@ -31,7 +31,6 @@ import { useSession } from "@/context/session" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Diff } from "@opencode-ai/ui/diff" -import { Terminal } from "@/components/terminal" export default function Page() { const layout = useLayout() @@ -55,14 +54,6 @@ export default function Page() { document.removeEventListener("keydown", handleKeyDown) }) - createEffect(() => { - if (layout.terminal.opened()) { - if (session.terminal.all().length === 0) { - session.terminal.new() - } - } - }) - const handleKeyDown = (event: KeyboardEvent) => { if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { event.preventDefault() @@ -82,16 +73,6 @@ export default function Page() { document.documentElement.setAttribute("data-theme", nextTheme) return } - if (event.ctrlKey && event.key.toLowerCase() === "`") { - event.preventDefault() - layout.terminal.toggle() - return - } - - // @ts-expect-error - if (document.activeElement?.dataset?.component === "terminal") { - return - } const focused = document.activeElement === inputRef if (focused) { @@ -160,7 +141,7 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.all + const currentTabs = session.layout.tabs.opened const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { @@ -278,397 +259,317 @@ export default function Page() { const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) return ( -
-
- - - - -
- - -
-
Session
- - -
{session.usage.context() ?? 0}%
-
-
-
- - - } +
+ + + + +
+ + +
+
Session
+ -
- - - -
-
Review
- -
- {session.info()?.summary?.files ?? 0} -
-
-
-
- - - - - {(tab) => ( - - )} - - -
- - setStore("fileSelectOpen", true)} - /> + +
{session.usage.context() ?? 0}%
- -
- +
+ + + } + > +
+ + + +
+
Review
+ +
+ {session.info()?.summary?.files ?? 0} +
+
+
+
+
+
+ + + {(tab) => } + + +
+ + setStore("fileSelectOpen", true)} + /> + +
+
+
+ +
-
- - -
- - 1 - ? "pr-6 pl-18" - : "px-6"), - }} - diffComponent={Diff} - /> -
-
- -
-
New session
-
- -
- {getDirectory(sync.data.path.directory)} - {getFilename(sync.data.path.directory)} -
+ + +
+ + 1 + ? "pr-6 pl-18" + : "px-6"), + }} + diffComponent={Diff} + /> +
+
+ +
+
New session
+
+ +
+ {getDirectory(sync.data.path.directory)} + {getFilename(sync.data.path.directory)}
-
- -
- Last modified  - - {DateTime.fromMillis(sync.data.project.time.created).toRelative()} - -
+
+
+ +
+ Last modified  + + {DateTime.fromMillis(sync.data.project.time.created).toRelative()} +
- - -
-
- { - inputRef = el - }} - />
-
-
- -
- + +
+
+ { + inputRef = el }} - diffs={session.diffs()} - diffComponent={Diff} - actions={ - - { - layout.review.tab() - session.layout.setActiveTab("review") - }} - /> - - } />
- +
- - - +
+ { + layout.review.tab() + session.layout.setActiveTab("review") + }} + /> + + } />
-
-
- - {(tab) => { - const [file] = createResource( - () => tab, - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( - - - - {(f) => ( - - )} - - - - ) - }} - - - - - {(draggedFile) => { - const [file] = createResource( - () => draggedFile(), - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( -
- {(f) => } -
- ) - }} -
-
- - -
- { - inputRef = el - }} - /> -
-
- - } - > -
    - - {(path) => ( -
  • - -
  • - )} -
    -
-
-
- - x} - onOpenChange={(open) => setStore("fileSelectOpen", open)} - onSelect={(x) => { - if (x) { - local.file.open(x) - return session.layout.openTab("file://" + x) - } - return undefined - }} - > - {(i) => ( + +
+ + +
-
- -
- - {getDirectory(i)} - - {getFilename(i)} -
-
-
+
- )} - -
-
- -
-
{ - e.preventDefault() - const startY = e.clientY - const startHeight = layout.terminal.height() - const maxHeight = window.innerHeight * 0.6 - const minHeight = 100 - const collapseThreshold = 50 - let currentHeight = startHeight - - document.body.style.userSelect = "none" - document.body.style.overflow = "hidden" - - const onMouseMove = (moveEvent: MouseEvent) => { - const deltaY = startY - moveEvent.clientY - currentHeight = startHeight + deltaY - const clampedHeight = Math.min(maxHeight, Math.max(minHeight, currentHeight)) - layout.terminal.resize(clampedHeight) - } - - const onMouseUp = () => { - document.body.style.userSelect = "" - document.body.style.overflow = "" - document.removeEventListener("mousemove", onMouseMove) - document.removeEventListener("mouseup", onMouseUp) - - if (currentHeight < collapseThreshold) { - layout.terminal.close() - } - } - - document.addEventListener("mousemove", onMouseMove) - document.addEventListener("mouseup", onMouseUp) + + + + {(tab) => { + const [file] = createResource( + () => tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + + + + {(f) => ( + + )} + + + + ) + }} + + + + + {(draggedFile) => { + const [file] = createResource( + () => draggedFile(), + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( +
+ {(f) => } +
+ ) + }} +
+
+ + +
+ { + inputRef = el }} /> - - - - {(terminal) => ( - 1 && ( - session.terminal.close(terminal.id)} /> - ) - } +
+
+ + }> +
    + + {(path) => ( +
  • + +
  • )}
    - -
+ + +
+ + x} + onOpenChange={(open) => setStore("fileSelectOpen", open)} + onSelect={(x) => { + if (x) { + local.file.open(x) + return session.layout.openTab("file://" + x) + } + return undefined + }} + > + {(i) => ( +
+
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
+
+
+
+ )} +
) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2c5441998b5..46c8c3200a6 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -72,7 +72,6 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", - "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", "decimal.js": "10.5.0", diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 76f78f3faa8..7754b4a3953 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -5,7 +5,6 @@ import { Instance } from "@/project/instance" import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" -import type { BunWebSocketData } from "hono/bun" await Log.init({ print: process.argv.includes("--print-logs"), @@ -28,7 +27,7 @@ process.on("uncaughtException", (e) => { }) }) -let server: Bun.Server +let server: Bun.Server export const rpc = { async server(input: { port: number; hostname: string }) { if (server) await server.stop(true) @@ -54,9 +53,7 @@ export const rpc = { async shutdown() { Log.Default.info("worker shutting down") await Instance.disposeAll() - // TODO: this should be awaited, but ws connections are - // causing this to hang, need to revisit this - server.stop(true) + await server.stop(true) }, } diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index ad6e22e1bee..99eb6c9ff06 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -8,7 +8,6 @@ export namespace Identifier { permission: "per", user: "usr", part: "prt", - pty: "pty", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts deleted file mode 100644 index efb519ff2a7..00000000000 --- a/packages/opencode/src/pty/index.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { spawn, type IPty } from "bun-pty" -import z from "zod" -import { Identifier } from "../id/id" -import { Log } from "../util/log" -import { Bus } from "../bus" -import type { WSContext } from "hono/ws" -import { Instance } from "../project/instance" -import { shell } from "@opencode-ai/util/shell" - -export namespace Pty { - const log = Log.create({ service: "pty" }) - - export const Info = z - .object({ - id: Identifier.schema("pty"), - title: z.string(), - command: z.string(), - args: z.array(z.string()), - cwd: z.string(), - status: z.enum(["running", "exited"]), - pid: z.number(), - }) - .meta({ ref: "Pty" }) - - export type Info = z.infer - - export const CreateInput = z.object({ - command: z.string().optional(), - args: z.array(z.string()).optional(), - cwd: z.string().optional(), - title: z.string().optional(), - env: z.record(z.string(), z.string()).optional(), - }) - - export type CreateInput = z.infer - - export const UpdateInput = z.object({ - title: z.string().optional(), - size: z - .object({ - rows: z.number(), - cols: z.number(), - }) - .optional(), - }) - - export type UpdateInput = z.infer - - export const Event = { - Created: Bus.event("pty.created", z.object({ info: Info })), - Updated: Bus.event("pty.updated", z.object({ info: Info })), - Exited: Bus.event("pty.exited", z.object({ id: Identifier.schema("pty"), exitCode: z.number() })), - Deleted: Bus.event("pty.deleted", z.object({ id: Identifier.schema("pty") })), - } - - interface ActiveSession { - info: Info - process: IPty - buffer: string - subscribers: Set - } - - const state = Instance.state( - () => new Map(), - async (sessions) => { - for (const session of sessions.values()) { - try { - session.process.kill() - } catch {} - for (const ws of session.subscribers) { - ws.close() - } - } - sessions.clear() - }, - ) - - export function list() { - return Array.from(state().values()).map((s) => s.info) - } - - export function get(id: string) { - return state().get(id)?.info - } - - export async function create(input: CreateInput) { - const id = Identifier.create("pty", false) - const command = input.command || shell() - const args = input.args || [] - const cwd = input.cwd || Instance.directory - const env = { ...process.env, ...input.env } as Record - log.info("creating session", { id, cmd: command, args, cwd }) - - const ptyProcess = spawn(command, args, { - name: "xterm-256color", - cwd, - env, - }) - const info = { - id, - title: input.title || `Terminal ${id.slice(-4)}`, - command, - args, - cwd, - status: "running", - pid: ptyProcess.pid, - } as const - const session: ActiveSession = { - info, - process: ptyProcess, - buffer: "", - subscribers: new Set(), - } - state().set(id, session) - ptyProcess.onData((data) => { - if (session.subscribers.size === 0) { - session.buffer += data - return - } - for (const ws of session.subscribers) { - if (ws.readyState === 1) { - ws.send(data) - } - } - }) - ptyProcess.onExit(({ exitCode }) => { - log.info("session exited", { id, exitCode }) - session.info.status = "exited" - Bus.publish(Event.Exited, { id, exitCode }) - state().delete(id) - }) - Bus.publish(Event.Created, { info }) - return info - } - - export async function update(id: string, input: UpdateInput) { - const session = state().get(id) - if (!session) return - if (input.title) { - session.info.title = input.title - } - if (input.size) { - session.process.resize(input.size.cols, input.size.rows) - } - Bus.publish(Event.Updated, { info: session.info }) - return session.info - } - - export async function remove(id: string) { - const session = state().get(id) - if (!session) return - log.info("removing session", { id }) - try { - session.process.kill() - } catch {} - for (const ws of session.subscribers) { - ws.close() - } - state().delete(id) - Bus.publish(Event.Deleted, { id }) - } - - export function resize(id: string, cols: number, rows: number) { - const session = state().get(id) - if (session && session.info.status === "running") { - session.process.resize(cols, rows) - } - } - - export function write(id: string, data: string) { - const session = state().get(id) - if (session && session.info.status === "running") { - session.process.write(data) - } - } - - export function connect(id: string, ws: WSContext) { - const session = state().get(id) - if (!session) { - ws.close() - return - } - log.info("client connected to session", { id }) - session.subscribers.add(ws) - if (session.buffer) { - ws.send(session.buffer) - session.buffer = "" - } - return { - onMessage: (message: string | ArrayBuffer) => { - session.process.write(String(message)) - }, - onClose: () => { - log.info("client disconnected from session", { id }) - session.subscribers.delete(ws) - }, - } - } -} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts deleted file mode 100644 index 26e2dfcb121..00000000000 --- a/packages/opencode/src/server/error.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { resolver } from "hono-openapi" -import z from "zod" -import { Storage } from "../storage/storage" - -export const ERRORS = { - 400: { - description: "Bad request", - content: { - "application/json": { - schema: resolver( - z - .object({ - data: z.any(), - errors: z.array(z.record(z.string(), z.any())), - success: z.literal(false), - }) - .meta({ - ref: "BadRequestError", - }), - ), - }, - }, - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: resolver(Storage.NotFoundError.Schema), - }, - }, - }, -} as const - -export function errors(...codes: number[]) { - return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) -} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a74b7876f1c..31d0822762b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -43,13 +43,43 @@ import { Snapshot } from "@/snapshot" import { SessionSummary } from "@/session/summary" import { GlobalBus } from "@/bus/global" import { SessionStatus } from "@/session/status" -import { upgradeWebSocket, websocket } from "hono/bun" -import { errors } from "./error" -import { Pty } from "@/pty" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false +const ERRORS = { + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + z + .object({ + data: z.any(), + errors: z.array(z.record(z.string(), z.any())), + success: z.literal(false), + }) + .meta({ + ref: "BadRequestError", + }), + ), + }, + }, + }, + 404: { + description: "Not found", + content: { + "application/json": { + schema: resolver(Storage.NotFoundError.Schema), + }, + }, + }, +} as const + +function errors(...codes: number[]) { + return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) +} + export namespace Server { const log = Log.create({ service: "server" }) @@ -162,167 +192,7 @@ export namespace Server { }), ) .use(validator("query", z.object({ directory: z.string().optional() }))) - .route("/project", ProjectRoute) - - .get( - "/pty", - describeRoute({ - description: "List all PTY sessions", - operationId: "pty.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Pty.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(Pty.list()) - }, - ) - .post( - "/pty", - describeRoute({ - description: "Create a new PTY session", - operationId: "pty.create", - responses: { - 200: { - description: "Created session", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Pty.CreateInput), - async (c) => { - const info = await Pty.create(c.req.valid("json")) - return c.json(info) - }, - ) - .put( - "/pty/:id", - describeRoute({ - description: "Update PTY session", - operationId: "pty.update", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("param", z.object({ id: z.string() })), - validator("json", Pty.UpdateInput), - async (c) => { - const info = await Pty.update(c.req.valid("param").id, c.req.valid("json")) - return c.json(info) - }, - ) - .get( - "/pty/:id", - describeRoute({ - description: "Get PTY session info", - operationId: "pty.get", - responses: { - 200: { - description: "Session info", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ id: z.string() })), - async (c) => { - const info = Pty.get(c.req.valid("param").id) - if (!info) { - throw new Storage.NotFoundError({ message: "Session not found" }) - } - return c.json(info) - }, - ) - .delete( - "/pty/:id", - describeRoute({ - description: "Remove a PTY session", - operationId: "pty.remove", - responses: { - 200: { - description: "Session removed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ id: z.string() })), - async (c) => { - await Pty.remove(c.req.valid("param").id) - return c.json(true) - }, - ) - .get( - "/pty/:id/connect", - describeRoute({ - description: "Connect to a PTY session", - operationId: "pty.connect", - responses: { - 200: { - description: "Connected session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - 404: { - description: "Session not found", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ id: z.string() })), - upgradeWebSocket((c) => { - const id = c.req.param("id") - let handler: ReturnType - return { - onOpen(_event, ws) { - handler = Pty.connect(id, ws) - }, - onMessage(event) { - handler?.onMessage(String(event.data)) - }, - onClose() { - handler?.onClose() - }, - } - }), - ) - .get( "/config", describeRoute({ @@ -2213,7 +2083,6 @@ export namespace Server { hostname: opts.hostname, idleTimeout: 0, fetch: App().fetch, - websocket: websocket, }) return server } diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index d04277cbc81..0dc470566ee 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -8,23 +8,6 @@ import type { ProjectListResponses, ProjectCurrentData, ProjectCurrentResponses, - PtyListData, - PtyListResponses, - PtyCreateData, - PtyCreateResponses, - PtyCreateErrors, - PtyRemoveData, - PtyRemoveResponses, - PtyRemoveErrors, - PtyGetData, - PtyGetResponses, - PtyGetErrors, - PtyUpdateData, - PtyUpdateResponses, - PtyUpdateErrors, - PtyConnectData, - PtyConnectResponses, - PtyConnectErrors, ConfigGetData, ConfigGetResponses, ConfigUpdateData, @@ -248,76 +231,6 @@ class Project extends _HeyApiClient { } } -class Pty extends _HeyApiClient { - /** - * List all PTY sessions - */ - public list(options?: Options) { - return (options?.client ?? this._client).get({ - url: "/pty", - ...options, - }) - } - - /** - * Create a new PTY session - */ - public create(options?: Options) { - return (options?.client ?? this._client).post({ - url: "/pty", - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }) - } - - /** - * Remove a PTY session - */ - public remove(options: Options) { - return (options.client ?? this._client).delete({ - url: "/pty/{id}", - ...options, - }) - } - - /** - * Get PTY session info - */ - public get(options: Options) { - return (options.client ?? this._client).get({ - url: "/pty/{id}", - ...options, - }) - } - - /** - * Update PTY session - */ - public update(options: Options) { - return (options.client ?? this._client).put({ - url: "/pty/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options.headers, - }, - }) - } - - /** - * Connect to a PTY session - */ - public connect(options: Options) { - return (options.client ?? this._client).get({ - url: "/pty/{id}/connect", - ...options, - }) - } -} - class Config extends _HeyApiClient { /** * Get config info @@ -1092,7 +1005,6 @@ export class OpencodeClient extends _HeyApiClient { } global = new Global({ client: this._client }) project = new Project({ client: this._client }) - pty = new Pty({ client: this._client }) config = new Config({ client: this._client }) tool = new Tool({ client: this._client }) instance = new Instance({ client: this._client }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 58ba58d359c..6c80f0b7c52 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -655,45 +655,6 @@ export type EventTuiToastShow = { } } -export type Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number -} - -export type EventPtyCreated = { - type: "pty.created" - properties: { - info: Pty - } -} - -export type EventPtyUpdated = { - type: "pty.updated" - properties: { - info: Pty - } -} - -export type EventPtyExited = { - type: "pty.exited" - properties: { - id: string - exitCode: number - } -} - -export type EventPtyDeleted = { - type: "pty.deleted" - properties: { - id: string - } -} - export type EventServerConnected = { type: "server.connected" properties: { @@ -729,10 +690,6 @@ export type Event = | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted | EventServerConnected export type GlobalEvent = { @@ -751,21 +708,6 @@ export type Project = { } } -export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false -} - -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - /** * Custom keybind configurations */ @@ -1324,6 +1266,14 @@ export type Config = { } } +export type BadRequestError = { + data: unknown + errors: Array<{ + [key: string]: unknown + }> + success: false +} + export type ToolIds = Array export type ToolListItem = { @@ -1345,6 +1295,13 @@ export type VcsInfo = { branch: string } +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + export type TextPartInput = { id?: string type: "text" @@ -1657,181 +1614,6 @@ export type ProjectCurrentResponses = { export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] -export type PtyListData = { - body?: never - path?: never - query?: { - directory?: string - } - url: "/pty" -} - -export type PtyListResponses = { - /** - * List of sessions - */ - 200: Array -} - -export type PtyListResponse = PtyListResponses[keyof PtyListResponses] - -export type PtyCreateData = { - body?: { - command?: string - args?: Array - cwd?: string - title?: string - env?: { - [key: string]: string - } - } - path?: never - query?: { - directory?: string - } - url: "/pty" -} - -export type PtyCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors] - -export type PtyCreateResponses = { - /** - * Created session - */ - 200: Pty -} - -export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses] - -export type PtyRemoveData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - } - url: "/pty/{id}" -} - -export type PtyRemoveErrors = { - /** - * Not found - */ - 404: NotFoundError -} - -export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors] - -export type PtyRemoveResponses = { - /** - * Session removed - */ - 200: boolean -} - -export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses] - -export type PtyGetData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - } - url: "/pty/{id}" -} - -export type PtyGetErrors = { - /** - * Not found - */ - 404: NotFoundError -} - -export type PtyGetError = PtyGetErrors[keyof PtyGetErrors] - -export type PtyGetResponses = { - /** - * Session info - */ - 200: Pty -} - -export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses] - -export type PtyUpdateData = { - body?: { - title?: string - size?: { - rows: number - cols: number - } - } - path: { - id: string - } - query?: { - directory?: string - } - url: "/pty/{id}" -} - -export type PtyUpdateErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors] - -export type PtyUpdateResponses = { - /** - * Updated session - */ - 200: Pty -} - -export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] - -export type PtyConnectData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - } - url: "/pty/{id}/connect" -} - -export type PtyConnectErrors = { - /** - * Session not found - */ - 404: boolean -} - -export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] - -export type PtyConnectResponses = { - /** - * Connected session - */ - 200: boolean -} - -export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] - export type ConfigGetData = { body?: never path?: never diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 9e4f00a0de7..306d7964988 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -153,10 +153,10 @@ const newIcons = { stop: ``, enter: ``, "layout-left": ``, - "layout-left-partial": ``, + "layout-left-partial": ``, "layout-left-full": ``, "layout-right": ``, - "layout-right-partial": ``, + "layout-right-partial": ``, "layout-right-full": ``, "speech-bubble": ``, "align-right": ``, @@ -167,9 +167,6 @@ const newIcons = { "bubble-5": ``, github: ``, discord: ``, - "layout-bottom": ``, - "layout-bottom-partial": ``, - "layout-bottom-full": ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 96ddf174cd8..421215a78ce 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -20,7 +20,6 @@ [data-component="select-content"] { min-width: 4rem; - max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); border-width: 1px; @@ -40,7 +39,6 @@ } [data-slot="select-select-content-list"] { - min-height: 2rem; overflow-y: auto; max-height: 12rem; white-space: nowrap; diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 9ba1f177b56..464900ef97b 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,10 +1,10 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, splitProps, type ComponentProps } from "solid-js" +import { createMemo, type ComponentProps } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" -export type SelectProps = Omit>, "value" | "onSelect"> & { +export interface SelectProps { placeholder?: string options: T[] current?: T @@ -17,21 +17,10 @@ export type SelectProps = Omit>, "value" | " } export function Select(props: SelectProps & ButtonProps) { - const [local, others] = splitProps(props, [ - "class", - "classList", - "placeholder", - "options", - "current", - "value", - "label", - "groupBy", - "onSelect", - ]) const grouped = createMemo(() => { const result = pipe( - local.options, - groupBy((x) => (local.groupBy ? local.groupBy(x) : "")), + props.options, + groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))), entries(), map(([k, v]) => ({ category: k, options: v })), @@ -40,30 +29,28 @@ export function Select(props: SelectProps & ButtonProps) { }) return ( - // @ts-ignore - {...others} data-component="select" - value={local.current} + value={props.current} options={grouped()} - optionValue={(x) => (local.value ? local.value(x) : (x as string))} - optionTextValue={(x) => (local.label ? local.label(x) : (x as string))} + optionValue={(x) => (props.value ? props.value(x) : (x as string))} + optionTextValue={(x) => (props.label ? props.label(x) : (x as string))} optionGroupChildren="options" - placeholder={local.placeholder} - sectionComponent={(local) => ( - {local.section.rawValue.category} + placeholder={props.placeholder} + sectionComponent={(props) => ( + {props.section.rawValue.category} )} itemComponent={(itemProps) => ( - {local.label ? local.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} + {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} @@ -71,25 +58,24 @@ export function Select(props: SelectProps & ButtonProps) { )} onChange={(v) => { - local.onSelect?.(v ?? undefined) + props.onSelect?.(v ?? undefined) }} > data-slot="select-select-trigger-value"> {(state) => { - const selected = state.selectedOption() ?? local.current - if (!selected) return local.placeholder || "" - if (local.label) return local.label(selected) + const selected = state.selectedOption() ?? props.current + if (!selected) return props.placeholder || "" + if (props.label) return props.label(selected) return selected as string }} @@ -100,8 +86,8 @@ export function Select(props: SelectProps & ButtonProps) { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index d60edc5c509..d03e57320ff 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -6,7 +6,7 @@ background-color: var(--background-stronger); overflow: clip; - [data-slot="tabs-list"] { + [data-slot="tabs-tabs-list"] { height: 48px; width: 100%; position: relative; @@ -36,7 +36,7 @@ } } - [data-slot="tabs-trigger-wrapper"] { + [data-slot="tabs-tabs-trigger-wrapper"] { position: relative; height: 100%; display: flex; @@ -58,14 +58,14 @@ border-right: 1px solid var(--border-weak-base); background-color: var(--background-base); - [data-slot="tabs-trigger"] { + [data-slot="tabs-tabs-trigger"] { display: flex; align-items: center; justify-content: center; padding: 14px 24px; } - [data-slot="tabs-trigger-close-button"] { + [data-slot="tabs-tabs-trigger-close-button"] { display: flex; align-items: center; justify-content: center; @@ -84,12 +84,12 @@ box-shadow: 0 0 0 2px var(--border-focus); } &:has([data-hidden]) { - [data-slot="tabs-trigger-close-button"] { + [data-slot="tabs-tabs-trigger-close-button"] { opacity: 0; } &:hover { - [data-slot="tabs-trigger-close-button"] { + [data-slot="tabs-tabs-trigger-close-button"] { opacity: 1; } } @@ -98,23 +98,23 @@ color: var(--text-strong); background-color: transparent; border-bottom-color: transparent; - [data-slot="tabs-trigger-close-button"] { + [data-slot="tabs-tabs-trigger-close-button"] { opacity: 1; } } &:hover:not(:disabled):not([data-selected]) { color: var(--text-strong); } - &:has([data-slot="tabs-trigger-close-button"]) { + &:has([data-slot="tabs-tabs-trigger-close-button"]) { padding-right: 12px; - [data-slot="tabs-trigger"] { + [data-slot="tabs-tabs-trigger"] { padding-right: 0; } } } - [data-slot="tabs-content"] { + [data-slot="tabs-tabs-content"] { overflow-y: auto; flex: 1; @@ -129,80 +129,4 @@ outline: none; } } - - &[data-variant="alt"] { - [data-slot="tabs-list"] { - padding-left: 24px; - padding-right: 24px; - gap: 12px; - border-bottom: 1px solid var(--border-weak-base); - background-color: transparent; - - &::after { - border: none; - background-color: transparent; - } - &:empty::after { - display: none; - } - } - - [data-slot="tabs-trigger-wrapper"] { - border: none; - color: var(--text-base); - background-color: transparent; - border-bottom-width: 2px; - border-bottom-style: solid; - border-bottom-color: transparent; - gap: 4px; - - /* text-14-regular */ - font-family: var(--font-family-sans); - font-size: var(--font-size-base); - font-style: normal; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-x-large); /* 171.429% */ - letter-spacing: var(--letter-spacing-normal); - - [data-slot="tabs-trigger"] { - height: 100%; - padding: 4px; - background-color: transparent; - border-bottom-width: 2px; - border-bottom-color: transparent; - } - - [data-slot="tabs-trigger-close-button"] { - display: flex; - align-items: center; - justify-content: center; - } - - [data-component="icon-button"] { - width: 16px; - height: 16px; - margin: 0; - } - - &:has([data-selected]) { - color: var(--text-strong); - background-color: transparent; - border-bottom-color: var(--icon-strong-base); - } - - &:hover:not(:disabled):not([data-selected]) { - color: var(--text-strong); - } - - &:has([data-slot="tabs-trigger-close-button"]) { - padding-right: 0; - [data-slot="tabs-trigger"] { - padding-right: 0; - } - } - } - - /* [data-slot="tabs-content"] { */ - /* } */ - } } diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index d91ad3c4156..68acd88d4e1 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -2,9 +2,7 @@ import { Tabs as Kobalte } from "@kobalte/core/tabs" import { Show, splitProps, type JSX } from "solid-js" import type { ComponentProps, ParentProps } from "solid-js" -export interface TabsProps extends ComponentProps { - variant?: "normal" | "alt" -} +export interface TabsProps extends ComponentProps {} export interface TabsListProps extends ComponentProps {} export interface TabsTriggerProps extends ComponentProps { classes?: { @@ -16,12 +14,11 @@ export interface TabsTriggerProps extends ComponentProps export interface TabsContentProps extends ComponentProps {} function TabsRoot(props: TabsProps) { - const [split, rest] = splitProps(props, ["class", "classList", "variant"]) + const [split, rest] = splitProps(props, ["class", "classList"]) return ( ) { ]) return (
) { > {split.children} {(closeButton) => ( -
+
{closeButton()}
)} @@ -84,7 +81,7 @@ function TabsContent(props: ParentProps) { return ( Date: Thu, 4 Dec 2025 22:02:17 +0000 Subject: [PATCH 22/23] release: v1.0.133 --- bun.lock | 30 +++++++++++++------------- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 4 ++-- packages/sdk/js/package.json | 4 ++-- packages/slack/package.json | 2 +- packages/tauri/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index aad651621cb..204f0df73a5 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -48,7 +48,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -75,7 +75,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -99,7 +99,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -123,7 +123,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -164,7 +164,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -192,7 +192,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "22.0.0", @@ -208,7 +208,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.132", + "version": "1.0.133", "bin": { "opencode": "./bin/opencode", }, @@ -297,7 +297,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -317,7 +317,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.132", + "version": "1.0.133", "devDependencies": { "@hey-api/openapi-ts": "0.81.0", "@tsconfig/node22": "catalog:", @@ -328,7 +328,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -341,7 +341,7 @@ }, "packages/tauri": { "name": "@opencode-ai/tauri", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", @@ -354,7 +354,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -386,7 +386,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "zod": "catalog:", }, @@ -397,7 +397,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 0a2c9c61bbb..15f00d6f322 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.132", + "version": "1.0.133", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 290127d3ef6..d324589b94b 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.132", + "version": "1.0.133", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 4f0955fd79a..0a3149cb1df 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.132", + "version": "1.0.133", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 3d6099b4d8b..9f55fd8f26e 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.132", + "version": "1.0.133", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f2f8768cbb8..483cf85ef5a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "1.0.132", + "version": "1.0.133", "description": "", "type": "module", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 5a99f880ff4..a007bdcd959 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.132", + "version": "1.0.133", "private": true, "type": "module", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 89598d4db6e..8a603582aae 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The AI coding agent built for the terminal" -version = "1.0.132" +version = "1.0.133" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.132/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.133/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.132/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.133/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.132/opencode-linux-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.133/opencode-linux-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.132/opencode-linux-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.133/opencode-linux-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.132/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.133/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 7bacb052bb5..be6e1b65c00 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.132", + "version": "1.0.133", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 46c8c3200a6..e14aed5ffbb 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.132", + "version": "1.0.133", "name": "opencode", "type": "module", "private": true, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index da5e3450e3e..7af6a984ec6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.132", + "version": "1.0.133", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a811885e1dc..470c6aa023c 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.132", + "version": "1.0.133", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} +} \ No newline at end of file diff --git a/packages/slack/package.json b/packages/slack/package.json index 4808543d592..2570f0877a4 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.132", + "version": "1.0.133", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/tauri/package.json b/packages/tauri/package.json index b368f4a08b6..9c3964dbab1 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/tauri", "private": true, - "version": "1.0.132", + "version": "1.0.133", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/ui/package.json b/packages/ui/package.json index ec2d740414a..a20d19f6214 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.132", + "version": "1.0.133", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/util/package.json b/packages/util/package.json index 75f8bec7485..4ac022e0cbe 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.132", + "version": "1.0.133", "private": true, "type": "module", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 666d40a33f9..5e5b178f766 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.132", + "version": "1.0.133", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 448489ac5a3..f71048523cd 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.132", + "version": "1.0.133", "publisher": "sst-dev", "repository": { "type": "git", From d953c4bbfd7608e7431781a8b991552654bd3989 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 4 Dec 2025 22:09:31 +0000 Subject: [PATCH 23/23] sync: record last synced tag v1.0.133 --- .github/last-synced-tag | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/last-synced-tag b/.github/last-synced-tag index b0397e1b986..d53b6bb1f63 100644 --- a/.github/last-synced-tag +++ b/.github/last-synced-tag @@ -1 +1 @@ -v1.0.132 +v1.0.133