diff --git a/README.bs.md b/README.bs.md new file mode 100644 index 0000000000..56a1e72fb6 --- /dev/null +++ b/README.bs.md @@ -0,0 +1,136 @@ +

+ + + + + OpenCode logo + + +

+

OpenCode je open source AI agent za programiranje.

+

+ Discord + npm + Build status +

+ +

+ English | + 简体中文 | + 繁體中文 | + 한국어 | + Deutsch | + Español | + Français | + Italiano | + Dansk | + 日本語 | + Polski | + Русский | + Bosanski | + العربية | + Norsk | + Português (Brasil) | + ไทย | + Türkçe +

+ +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) + +--- + +### Instalacija + +```bash +# YOLO +curl -fsSL https://opencode.ai/install | bash + +# Package manageri +npm i -g opencode-ai@latest # ili bun/pnpm/yarn +scoop install opencode # Windows +choco install opencode # Windows +brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno) +brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira) +paru -S opencode-bin # Arch Linux +mise use -g opencode # Bilo koji OS +nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch +``` + +> [!TIP] +> Ukloni verzije starije od 0.1.x prije instalacije. + +### Desktop aplikacija (BETA) + +OpenCode je dostupan i kao desktop aplikacija. Preuzmi je direktno sa [stranice izdanja](https://github.com/anomalyco/opencode/releases) ili sa [opencode.ai/download](https://opencode.ai/download). + +| Platforma | Preuzimanje | +| --------------------- | ------------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | +| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, ili AppImage | + +```bash +# macOS (Homebrew) +brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop +``` + +#### Instalacijski direktorij + +Instalacijska skripta koristi sljedeći redoslijed prioriteta za putanju instalacije: + +1. `$OPENCODE_INSTALL_DIR` - Prilagođeni instalacijski direktorij +2. `$XDG_BIN_DIR` - Putanja usklađena sa XDG Base Directory specifikacijom +3. `$HOME/bin` - Standardni korisnički bin direktorij (ako postoji ili se može kreirati) +4. `$HOME/.opencode/bin` - Podrazumijevana rezervna lokacija + +```bash +# Primjeri +OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash +``` + +### Agenti + +OpenCode uključuje dva ugrađena agenta između kojih možeš prebacivati tasterom `Tab`. + +- **build** - Podrazumijevani agent sa punim pristupom za razvoj +- **plan** - Agent samo za čitanje za analizu i istraživanje koda + - Podrazumijevano zabranjuje izmjene datoteka + - Traži dozvolu prije pokretanja bash komandi + - Idealan za istraživanje nepoznatih codebase-ova ili planiranje izmjena + +Uključen je i **general** pod-agent za složene pretrage i višekoračne zadatke. +Koristi se interno i može se pozvati pomoću `@general` u porukama. + +Saznaj više o [agentima](https://opencode.ai/docs/agents). + +### Dokumentacija + +Za više informacija o konfiguraciji OpenCode-a, [**pogledaj dokumentaciju**](https://opencode.ai/docs). + +### Doprinosi + +Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIBUTING.md) prije slanja pull requesta. + +### Gradnja na OpenCode-u + +Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama. + +### FAQ + +#### Po čemu se razlikuje od Claude Code-a? + +Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su: + +- 100% open source +- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna. +- LSP podrška odmah po instalaciji +- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu. +- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata. + +--- + +**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.md b/README.md index 671a9072bf..2cd1e2aa01 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | diff --git a/bun.lock b/bun.lock index 1c7aecdb9b..a576e3a94d 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -44,7 +44,7 @@ "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "0.3.0", + "ghostty-web": "0.4.0", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -213,7 +213,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -242,7 +242,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -258,14 +258,14 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.51", + "version": "1.1.52", "bin": { "opencode": "./bin/opencode", }, "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.13.0", + "@agentclientprotocol/sdk": "0.14.1", "@ai-sdk/amazon-bedrock": "3.0.74", "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/azure": "2.0.91", @@ -367,7 +367,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -387,7 +387,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.51", + "version": "1.1.52", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -398,7 +398,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -411,7 +411,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -453,7 +453,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "zod": "catalog:", }, @@ -464,7 +464,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.51", + "version": "1.1.52", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -499,9 +499,6 @@ "web-tree-sitter", "tree-sitter-bash", ], - "patchedDependencies": { - "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch", - }, "overrides": { "@types/bun": "catalog:", "@types/node": "catalog:", @@ -564,7 +561,7 @@ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="], "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="], @@ -2624,7 +2621,7 @@ "get-tsconfig": ["get-tsconfig@4.13.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-vp8Cj/+9Q/ibZUrq1rhy8mCTQpCk31A3uu9wc1C50yAb3x2pFHOsGdAZQ7jD86ARayyxZUViYeIztW+GE8dcrg=="], - "ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="], + "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], diff --git a/nix/hashes.json b/nix/hashes.json index e4ef98a0d4..751442d756 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-/wjmXpex5zfBZBfc2Zszh9UurRuKjm8S+gdAkMrWL98=", - "aarch64-linux": "sha256-c254hgVVyLFucacOQlpnJ+3eCw8xIK9+388EigiuKeM=", - "aarch64-darwin": "sha256-A71PII7Ue0uqsH970GPi0XfRKInpSFFom8jD3Q0wrgQ=", - "x86_64-darwin": "sha256-M9q8O0GkiNoDPMW4C3EVvLo+X9mjQYLJTtNZx5AuQCc=" + "x86_64-linux": "sha256-ufEpxjmlJeft9tI+WxxO+Zbh1pdAaLOURCDBpoQqR0w=", + "aarch64-linux": "sha256-z3K6W5oYZNUdV0rjoAZjvNQcifM5bXamLIrD+ZvJ4kA=", + "aarch64-darwin": "sha256-+QikplmNhxGF2Nd4L1BG/xyl+24GVhDYMTtK6xCKy/s=", + "x86_64-darwin": "sha256-hAcrCT2X02ymwgj/0BAmD2gF66ylGYzbfcqPta/LVEU=" } } diff --git a/package.json b/package.json index 60d96428f7..7846ce6ea6 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,5 @@ "@types/bun": "catalog:", "@types/node": "catalog:" }, - "patchedDependencies": { - "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" - } + "patchedDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 3a492ed919..12688ac079 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.51", + "version": "1.1.52", "description": "", "type": "module", "exports": { @@ -54,7 +54,7 @@ "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "0.3.0", + "ghostty-web": "0.4.0", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 167f211953..36448dd3e6 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -47,6 +47,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const filesOnly = () => props.mode === "files" const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) const state = { cleanup: undefined as (() => void) | void, committed: false } const [grouped, setGrouped] = createSignal(false) const common = [ @@ -282,6 +283,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const value = file.tab(path) tabs().open(value) file.load(path) + if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.open() layout.fileTree.setTab("all") props.onOpenFile?.(path) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index a4cfe4a90e..491a16de77 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -241,7 +241,7 @@ export default function FileTree(props: { : kind === "del" ? "color: var(--icon-diff-delete-base)" : kind === "mix" - ? "color: var(--icon-diff-modified-base)" + ? "color: var(--icon-warning-active)" : undefined return ( @@ -283,7 +283,7 @@ export default function FileTree(props: { ? "background-color: var(--icon-diff-add-base)" : kind === "del" ? "background-color: var(--icon-diff-delete-base)" - : "background-color: var(--icon-diff-modified-base)" + : "background-color: var(--icon-warning-active)" return
} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index b897e394aa..9186dcfa3e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -172,6 +172,7 @@ export const PromptInput: Component = (props) => { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) const commentInReview = (path: string) => { const sessionID = params.id @@ -190,12 +191,14 @@ export const PromptInput: Component = (props) => { const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) if (wantsReview) { + if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.open() layout.fileTree.setTab("changes") requestAnimationFrame(() => comments.setFocus(focus)) return } + if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.open() layout.fileTree.setTab("all") const tab = files.tab(item.path) @@ -1796,7 +1799,7 @@ export const PromptInput: Component = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all" + class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx new file mode 100644 index 0000000000..f626fcc9b2 --- /dev/null +++ b/packages/app/src/components/question-dock.tsx @@ -0,0 +1,295 @@ +import { For, Show, createMemo, type Component } from "solid-js" +import { createStore } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" + +export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { + const sdk = useSDK() + const language = useLanguage() + + const questions = createMemo(() => props.request.questions) + const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) + + const [store, setStore] = createStore({ + tab: 0, + answers: [] as QuestionAnswer[], + custom: [] as string[], + editing: false, + sending: false, + }) + + const question = createMemo(() => questions()[store.tab]) + const confirm = createMemo(() => !single() && store.tab === questions().length) + const options = createMemo(() => question()?.options ?? []) + const input = createMemo(() => store.custom[store.tab] ?? "") + const multi = createMemo(() => question()?.multiple === true) + const customPicked = createMemo(() => { + const value = input() + if (!value) return false + return store.answers[store.tab]?.includes(value) ?? false + }) + + const fail = (err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + } + + const reply = (answers: QuestionAnswer[]) => { + if (store.sending) return + + setStore("sending", true) + sdk.client.question + .reply({ requestID: props.request.id, answers }) + .catch(fail) + .finally(() => setStore("sending", false)) + } + + const reject = () => { + if (store.sending) return + + setStore("sending", true) + sdk.client.question + .reject({ requestID: props.request.id }) + .catch(fail) + .finally(() => setStore("sending", false)) + } + + const submit = () => { + reply(questions().map((_, i) => store.answers[i] ?? [])) + } + + const pick = (answer: string, custom: boolean = false) => { + const answers = [...store.answers] + answers[store.tab] = [answer] + setStore("answers", answers) + + if (custom) { + const inputs = [...store.custom] + inputs[store.tab] = answer + setStore("custom", inputs) + } + + if (single()) { + reply([[answer]]) + return + } + + setStore("tab", store.tab + 1) + } + + const toggle = (answer: string) => { + const existing = store.answers[store.tab] ?? [] + const next = [...existing] + const index = next.indexOf(answer) + if (index === -1) next.push(answer) + if (index !== -1) next.splice(index, 1) + + const answers = [...store.answers] + answers[store.tab] = next + setStore("answers", answers) + } + + const selectTab = (index: number) => { + setStore("tab", index) + setStore("editing", false) + } + + const selectOption = (optIndex: number) => { + if (store.sending) return + + if (optIndex === options().length) { + setStore("editing", true) + return + } + + const opt = options()[optIndex] + if (!opt) return + if (multi()) { + toggle(opt.label) + return + } + pick(opt.label) + } + + const handleCustomSubmit = (e: Event) => { + e.preventDefault() + if (store.sending) return + + const value = input().trim() + if (!value) { + setStore("editing", false) + return + } + + if (multi()) { + const existing = store.answers[store.tab] ?? [] + const next = [...existing] + if (!next.includes(value)) next.push(value) + + const answers = [...store.answers] + answers[store.tab] = next + setStore("answers", answers) + setStore("editing", false) + return + } + + pick(value, true) + setStore("editing", false) + } + + return ( +
+ +
+ + {(q, index) => { + const active = () => index() === store.tab + const answered = () => (store.answers[index()]?.length ?? 0) > 0 + return ( + + ) + }} + + +
+
+ + +
+
+ {question()?.question} + {multi() ? " " + language.t("ui.question.multiHint") : ""} +
+
+ + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + + ) + }} + + + +
+ setTimeout(() => el.focus(), 0)} + type="text" + data-slot="custom-input" + placeholder={language.t("ui.question.custom.placeholder")} + value={input()} + disabled={store.sending} + onInput={(e) => { + const inputs = [...store.custom] + inputs[store.tab] = e.currentTarget.value + setStore("custom", inputs) + }} + /> + + +
+
+
+
+
+ + +
+
{language.t("ui.messagePart.review.title")}
+ + {(q, index) => { + const value = () => store.answers[index()]?.join(", ") ?? "" + const answered = () => Boolean(value()) + return ( +
+ {q.question} + + {answered() ? value() : language.t("ui.question.review.notAnswered")} + +
+ ) + }} +
+
+
+ +
+ + + + + + + + + +
+
+ ) +} diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c5de54cf0f..c6256395fc 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -23,6 +23,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const variant = createMemo(() => props.variant ?? "button") const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const usd = createMemo( @@ -57,6 +58,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const openContext = () => { if (!params.id) return + if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.open() layout.fileTree.setTab("all") tabs().open("context") diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 5b00f80c05..43057d63b9 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,18 +6,23 @@ import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" import { decode64 } from "@/utils/base64" +import { Persist, persisted } from "@/utils/persist" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { Keybind } from "@opencode-ai/ui/keybind" +import { showToast } from "@opencode-ai/ui/toast" import { StatusPopover } from "../status-popover" export function SessionHeader() { @@ -25,6 +30,7 @@ export function SessionHeader() { const layout = useLayout() const params = useParams() const command = useCommand() + const server = useServer() const sync = useSync() const platform = usePlatform() const language = useLanguage() @@ -48,6 +54,117 @@ export function SessionHeader() { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) + const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "xcode", + "android-studio", + "powershell", + ] as const + type OpenApp = (typeof OPEN_APPS)[number] + + const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" + }) + + const options = createMemo(() => { + if (os() === "macos") { + return [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, + { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, + { id: "finder", label: "Finder", icon: "finder" }, + { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, + { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, + ] as const + } + + if (os() === "windows") { + return [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "finder", label: "File Explorer", icon: "finder" }, + { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, + ] as const + } + + return [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "finder", label: "File Manager", icon: "finder" }, + ] as const + }) + + const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) + + createEffect(() => { + if (platform.platform !== "desktop") return + const value = prefs.app + if (options().some((o) => o.id === value)) return + setPrefs("app", options()[0]?.id ?? "finder") + }) + + const openDir = (app: OpenApp) => { + const directory = projectDirectory() + if (!directory) return + if (!canOpen()) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }) + } + + const copyPath = () => { + const directory = projectDirectory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }) + } + const [state, setState] = createStore({ share: false, unshare: false, @@ -150,6 +267,76 @@ export function SessionHeader() { {(mount) => (
+ + + + {language.t("session.header.open.copyPath")} + + } + > +
+ + + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + setPrefs("app", value as OpenApp) + }} + > + {options().map((o) => ( + openDir(o.id)}> + + {o.label} + + + + + ))} + + + + + + + {language.t("session.header.open.copyPath")} + + + + + +
+
+
@@ -283,27 +470,57 @@ export function SessionHeader() { + +
+ + } + > + { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + /> +
- + {/* Desktop side panel - hidden on mobile */} - +