Compare commits

..

38 Commits

Author SHA1 Message Date
Adam
0a53f8e084 chore: cleanup 2026-03-11 13:56:16 -05:00
Adam
6f5b2f786e wip: node-pty 2026-03-11 13:47:20 -05:00
Luke Parker
7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Dax
6ad171dba9 Merge branch 'dev' into opencode-2-0 2026-03-10 17:20:19 -04:00
Dax Raad
cb5674edc7 sync 2026-03-10 17:00:15 -04:00
Dax Raad
b99de4118e refactor(npm): inline pkgPath and lockPath variables 2026-03-10 16:59:01 -04:00
Dax Raad
040700dbc4 unbreak 2026-03-10 16:07:25 -04:00
Dax Raad
4d5da9697e sync 2026-03-10 16:02:40 -04:00
Dax Raad
a28648f530 core: enable running in non-Bun environments by using standard Node.js APIs for OAuth servers and retry logic 2026-03-10 16:02:40 -04:00
Dax Raad
4d81e2d4d9 sync 2026-03-10 16:02:40 -04:00
Dax Raad
21e72cbf42 core: cleaner error output and more flexible custom tool directories
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
2026-03-10 16:02:40 -04:00
Dax Raad
5f277d1e62 core: return structured server info with stop method from workspace server
- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
2026-03-10 16:02:40 -04:00
Dax Raad
d67e877e28 core: remove shell execution and server URL from plugin API
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
2026-03-10 16:02:40 -04:00
Dax Raad
d4e51e04b3 sync 2026-03-10 16:02:40 -04:00
Dax Raad
070c1679e4 core: bundle database migrations into node build and auto-start server on port 1338 2026-03-10 16:02:40 -04:00
Dax Raad
406d216cd2 refactor(server): replace Bun serve with Hono node adapters 2026-03-10 16:02:40 -04:00
Dax Raad
5dc8b4ef29 core: add Node.js runtime support
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
2026-03-10 16:02:39 -04:00
Luke Parker
2f41d89163 fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (#16853) 2026-03-10 16:02:39 -04:00
Dax Raad
b2eae867a1 tui: fix Windows plugin loading by using direct paths instead of file URLs 2026-03-10 16:02:39 -04:00
Dax Raad
3c2fda4d91 core: fix custom tool loading to properly resolve module paths 2026-03-10 16:02:39 -04:00
Dax Raad
2678ceb45e sync 2026-03-10 16:02:39 -04:00
Dax Raad
58a4cd00b6 sync 2026-03-10 16:02:39 -04:00
Dax Raad
0faa191b6d sync 2026-03-10 16:02:39 -04:00
Dax Raad
58cf092105 core: log npm install errors to console for debugging dependency failures 2026-03-10 16:02:39 -04:00
Dax Raad
0ff8bfe1d9 sync 2026-03-10 16:02:39 -04:00
Dax Raad
ceb79c786a core: fix CLI tools from npm packages not being accessible after install on Windows 2026-03-10 16:02:39 -04:00
Dax Raad
b1a15d559b sync 2026-03-10 16:02:39 -04:00
Dax Raad
124a8abf9b tui: export sessions using consistent Filesystem API instead of Bun.write 2026-03-10 16:02:39 -04:00
Dax Raad
85c2bb342b core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
4c57e39466 core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted 2026-03-10 16:02:39 -04:00
Dax Raad
0cdd4e4e16 core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected 2026-03-10 16:02:39 -04:00
Dax Raad
a9b01be0c2 core: disable npm bin links to fix package installation in sandboxed environments 2026-03-10 16:02:39 -04:00
Dax Raad
528daf5490 core: dynamically resolve formatter executable paths at runtime
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
2026-03-10 16:02:39 -04:00
Dax Raad
0e176d3ac3 sync 2026-03-10 16:02:39 -04:00
Dax
27f359852e Update packages/opencode/src/util/which.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax
173128d431 Update packages/opencode/src/npm/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-10 16:02:39 -04:00
Dax Raad
e8ee1e239f sync 2026-03-10 16:02:39 -04:00
Dax Raad
656fa191c1 refactor: lsp server and core improvements 2026-03-10 16:02:39 -04:00
520 changed files with 9901 additions and 18607 deletions

View File

@@ -3,6 +3,14 @@ description: "Setup Bun with caching and install dependencies"
runs:
using: "composite"
steps:
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Get baseline download URL
id: bun-url
shell: bash
@@ -23,19 +31,6 @@ runs:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory
id: cache
shell: bash
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash

View File

@@ -115,9 +115,6 @@ jobs:
target: x86_64-apple-darwin
- host: macos-latest
target: aarch64-apple-darwin
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: windows-2025
target: aarch64-pc-windows-msvc
- host: blacksmith-4vcpu-windows-2025
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
@@ -152,10 +149,6 @@ jobs:
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
@@ -261,10 +254,6 @@ jobs:
- host: macos-latest
target: aarch64-apple-darwin
platform_flag: --mac --arm64
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: "windows-2025"
target: aarch64-pc-windows-msvc
platform_flag: --win --arm64
- host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc
platform_flag: --win

View File

@@ -8,9 +8,7 @@ on:
workflow_dispatch:
concurrency:
# Keep every run on dev so cancelled checks do not pollute the default branch
# commit history. PRs and other branches still share a group and cancel stale runs.
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:

2
.gitignore vendored
View File

@@ -17,7 +17,7 @@ ts-dist
/result
refs
Session.vim
/opencode.json
opencode.json
a.out
target
.scripts

View File

@@ -1,4 +1,6 @@
plans/
bun.lock
node_modules
plans
package.json
package-lock.json
bun.lock
.gitignore
package-lock.json

View File

@@ -1,6 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
@@ -24,7 +23,16 @@ interface PR {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),

View File

@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.

View File

@@ -1,6 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
@@ -40,7 +39,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])

View File

@@ -1,6 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.

View File

@@ -128,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
#### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:
It's very similar to Claude Code in terms of capability. Here are the key differences::
- 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.

View File

@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

View File

@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

1627
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -103,12 +103,6 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
const zenLiteProduct = new stripe.Product("ZenLite", {
name: "OpenCode Go",
})
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
name: "First month 50% off",
percentOff: 50,
appliesToProducts: [zenLiteProduct.id],
duration: "once",
})
const zenLitePrice = new stripe.Price("ZenLitePrice", {
product: zenLiteProduct.id,
currency: "usd",
@@ -122,7 +116,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
},
})

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=",
"aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=",
"aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=",
"x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI="
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
}
}

View File

@@ -11,6 +11,7 @@
"dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -41,9 +42,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "4.0.0-beta.31",
"effect": "4.0.0-beta.29",
"drizzle-kit": "1.0.0-beta.16-c2458b2",
"drizzle-orm": "1.0.0-beta.16-c2458b2",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -98,6 +99,7 @@
},
"trustedDependencies": [
"esbuild",
"node-pty",
"protobufjs",
"tree-sitter",
"tree-sitter-bash",

View File

@@ -70,8 +70,6 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session
- `withProject(...)` - Create temp project/workspace
- `sessionIDFromUrl(url)` - Read session ID from URL
@@ -169,32 +167,6 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings
```
### Terminal Tests
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
### Wait on state
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
- Avoid race-prone flows that assume work is finished after an action
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
### Add hooks
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
### Prefer helpers
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
- Use direct locators when the interaction is simple and a helper would not add clarity
## Writing New Tests
1. Choose appropriate folder or create new one

View File

@@ -3,7 +3,6 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
@@ -16,7 +15,6 @@ import {
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
terminalSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
@@ -30,69 +28,6 @@ export async function defocus(page: Page) {
.catch(() => undefined)
}
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
throw new Error(`Active terminal missing ${terminalAttr}`)
}
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
return page.evaluate((id) => {
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
}, id)
}
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
await page.evaluate((id) => {
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
}, id)
}
async function terminalReady(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return !!state?.connected && (state.settled ?? 0) > 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate(
(input) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
return state?.rendered.includes(input.token) ?? false
},
{ id, token: input.token },
)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await expect(term).toBeVisible()
await expect(term.locator("textarea")).toHaveCount(1)
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
const textarea = term.locator("textarea")
await term.click()
await expect(textarea).toBeFocused()
await page.keyboard.type(input.cmd)
await page.keyboard.press("Enter")
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
@@ -604,19 +539,12 @@ export async function seedSessionTask(
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
return false
if (!("sessionId" in part.state.metadata)) return false
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
if (part.state.input?.description !== input.description) return false
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
if (!("sessionId" in part.state.metadata)) return
const id = part.state.metadata.sessionId
if (!part) return
const id = part.state.metadata?.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })

View File

@@ -1,5 +1,4 @@
import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -92,14 +91,6 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
const win = window as E2EWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
terminal: {
enabled: true,
terminals: {},
},
}
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({

View File

@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -7,29 +6,18 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector)
const slash = page.locator('[data-slash-id="terminal.toggle"]').first()
await expect(terminal).not.toBeVisible()
await prompt.fill("/terminal")
await expect(slash).toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await waitTerminalReady(page, { term: terminal })
await expect(terminal).toBeVisible()
// Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening,
// which can steal focus from the prompt and prevent fill() from triggering
// the slash popover. Re-attempt click+fill until all retries are exhausted
// and the popover appears.
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill("/terminal").catch(() => false)
return slash.isVisible().catch(() => false)
},
{ timeout: 10_000 },
)
.toBe(true)
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()
})

View File

@@ -1,6 +1,5 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
export const terminalSelector = '[data-component="terminal"]'
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'

View File

@@ -1,16 +1,12 @@
import { test, expect } from "../fixtures"
import {
composerEvent,
type ComposerDriverState,
type ComposerProbeState,
type ComposerWindow,
} from "../../src/testing/session-composer"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
questionDockSelector,
sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
@@ -46,8 +42,12 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
async function clearPermissionDock(page: any, label: RegExp) {
const dock = page.locator(permissionDockSelector)
await expect(dock).toBeVisible()
await dock.getByRole("button", { name: label }).click()
for (let i = 0; i < 3; i++) {
const count = await dock.count()
if (count === 0) return
await dock.getByRole("button", { name: label }).click()
await page.waitForTimeout(150)
}
}
async function setAutoAccept(page: any, enabled: boolean) {
@@ -59,120 +59,6 @@ async function setAutoAccept(page: any, enabled: boolean) {
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
}
async function expectQuestionBlocked(page: any) {
await expect(page.locator(questionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectQuestionOpen(page: any) {
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function expectPermissionBlocked(page: any) {
await expect(page.locator(permissionDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toHaveCount(0)
}
async function expectPermissionOpen(page: any) {
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
}
async function todoDock(page: any, sessionID: string) {
await page.addInitScript(() => {
const win = window as ComposerWindow
win.__opencode_e2e = {
...win.__opencode_e2e,
composer: {
enabled: true,
sessions: {},
},
}
})
const write = async (driver: ComposerDriverState | undefined) => {
await page.evaluate(
(input) => {
const win = window as ComposerWindow
const composer = win.__opencode_e2e?.composer
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
composer.sessions ??= {}
const prev = composer.sessions[input.sessionID] ?? {}
if (!input.driver) {
if (!prev.probe) {
delete composer.sessions[input.sessionID]
} else {
composer.sessions[input.sessionID] = { probe: prev.probe }
}
} else {
composer.sessions[input.sessionID] = {
...prev,
driver: input.driver,
}
}
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
},
{ event: composerEvent, sessionID, driver },
)
}
const read = () =>
page.evaluate((sessionID) => {
const win = window as ComposerWindow
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
}, sessionID) as Promise<ComposerProbeState | null>
const api = {
async clear() {
await write(undefined)
return api
},
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: true, todos })
return api
},
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
await write({ live: false, todos })
return api
},
async expectOpen(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: false,
hidden: false,
count: states.length,
states,
})
return api
},
async expectCollapsed(states: ComposerProbeState["states"]) {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
mounted: true,
collapsed: true,
hidden: true,
count: states.length,
states,
})
return api
},
async expectClosed() {
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
return api
},
async collapse() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
async expand() {
await page.locator(sessionTodoToggleButtonSelector).click()
return api
},
}
return api
}
async function withMockPermission<T>(
page: any,
request: {
@@ -184,7 +70,7 @@ async function withMockPermission<T>(
always?: string[]
},
opts: { child?: any } | undefined,
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
fn: () => Promise<T>,
) {
let pending = [
{
@@ -233,14 +119,8 @@ async function withMockPermission<T>(
if (sessionList) await page.route("**/session?*", sessionList)
const state = {
async resolved() {
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
},
}
try {
return await fn(state)
return await fn()
} finally {
await page.unroute("**/permission", list)
await page.unroute("**/session/*/permissions/*", reply)
@@ -293,12 +173,14 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
@@ -317,14 +199,15 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
async () => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
})
@@ -343,14 +226,15 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async (state) => {
async () => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
})
@@ -370,14 +254,15 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
async () => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
})
@@ -416,12 +301,14 @@ test("child session question request blocks parent dock and unblocks after submi
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
} finally {
await cleanupSession({ sdk, sessionID: child.id })
@@ -457,15 +344,17 @@ test("child session permission request blocks parent dock and supports allow onc
metadata: { description: "Need child permission" },
},
{ child },
async (state) => {
async () => {
await page.goto(page.url())
await expectPermissionBlocked(page)
const dock = page.locator(permissionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
},
)
} finally {
@@ -476,31 +365,36 @@ test("child session permission request blocks parent dock and supports allow onc
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
const dock = await todoDock(page, session.id)
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
try {
await dock.open([
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
])
await dock.expectOpen(["pending", "in_progress"])
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
],
})
await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"])
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await dock.expand()
await dock.expectOpen(["pending", "in_progress"])
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
await dock.finish([
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
])
await dock.expectClosed()
} finally {
await dock.clear()
}
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
})
})
})
@@ -520,7 +414,8 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
],
})
await expectQuestionBlocked(page)
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")

View File

@@ -1,217 +0,0 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
const count = 14
function body(mark: string) {
return [
`title ${mark}`,
`mark ${mark}`,
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
]
}
function files(tag: string) {
return Array.from({ length: count }, (_, i) => {
const id = String(i).padStart(2, "0")
return {
file: `review-scroll-${id}.txt`,
mark: `${tag}-${id}`,
}
})
}
function seed(list: ReturnType<typeof files>) {
const out = ["*** Begin Patch"]
for (const item of list) {
out.push(`*** Add File: ${item.file}`)
for (const line of body(item.mark)) out.push(`+${line}`)
}
out.push("*** End Patch")
return out.join("\n")
}
function edit(file: string, prev: string, next: string) {
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
"\n",
)
}
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
await sdk.session.promptAsync({
sessionID,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})
await waitSessionIdle(sdk, sessionID, 120_000)
}
async function show(page: Parameters<typeof test>[0]["page"]) {
const btn = page.getByRole("button", { name: "Toggle review" }).first()
await expect(btn).toBeVisible()
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
await expect(btn).toHaveAttribute("aria-expanded", "true")
}
async function expand(page: Parameters<typeof test>[0]["page"]) {
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
const open = await close
.isVisible()
.then((value) => value)
.catch(() => false)
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
if (open) {
await close.click()
await expect(btn).toBeVisible()
}
await expect(btn).toBeVisible()
await btn.click()
await expect(close).toBeVisible()
}
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
await page.waitForFunction(
({ file, mark }) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return false
const head = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(head instanceof HTMLElement)) return false
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
if (!(host instanceof HTMLElement)) return false
const root = host.shadowRoot
return root?.textContent?.includes(`mark ${mark}`) ?? false
})
},
{ file, mark },
{ timeout: 60_000 },
)
}
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
return page.evaluate((file) => {
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
if (!(view instanceof HTMLElement)) return null
const row = Array.from(view.querySelectorAll("h3")).find(
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
)
if (!(row instanceof HTMLElement)) return null
const a = row.getBoundingClientRect()
const b = view.getBoundingClientRect()
return {
top: a.top - b.top,
y: view.scrollTop,
}
}, file)
}
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000)
const tag = `review-${Date.now()}`
const list = files(tag)
const hit = list[list.length - 4]!
const next = `${tag}-live`
await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await expect
.poll(
async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0
},
{ timeout: 60_000 },
)
.toBe(list.length)
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBe(list.length)
await project.gotoSession(session.id)
await show(page)
const tab = page.getByRole("tab", { name: /Review/i }).first()
await expect(tab).toBeVisible()
await tab.click()
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expand(page)
await waitMark(page, hit.file, hit.mark)
const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
.first()
await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
await expect
.poll(
async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : ""
},
{ timeout: 60_000 },
)
.toContain(`mark ${next}`)
await waitMark(page, hit.file, next)
await expect
.poll(
async () => {
const next = await spot(page, hit.file)
if (!next) return Number.POSITIVE_INFINITY
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
},
{ timeout: 60_000 },
)
.toBeLessThanOrEqual(32)
})
})
})

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, waitTerminalReady, withSession } from "../actions"
import { openSettings, closeDialog, withSession } from "../actions"
import { keybindButtonSelector, terminalSelector } from "../selectors"
import { modKey } from "../utils"
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await waitTerminalReady(page, { term: terminal })
await expect(terminal).toBeVisible()
await page.keyboard.press(`${modKey}+Y`)
await expect(terminal).not.toBeVisible()

View File

@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
@@ -14,7 +13,8 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await page.keyboard.press(terminalToggleKey)
}
await waitTerminalReady(page, { term: terminals.first() })
await expect(terminals.first()).toBeVisible()
await expect(terminals.first().locator("textarea")).toHaveCount(1)
await expect(terminals).toHaveCount(1)
// Ghostty captures a lot of keybinds when focused; move focus back
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
await expect(tabs).toHaveCount(2)
await expect(terminals).toHaveCount(1)
await waitTerminalReady(page, { term: terminals.first() })
await expect(terminals.first().locator("textarea")).toHaveCount(1)
})

View File

@@ -1,46 +0,0 @@
import type { Page } from "@playwright/test"
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
async function open(page: Page) {
const term = page.locator(terminalSelector).first()
const visible = await term.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term })
return term
}
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
await withProject(async ({ gotoSession }) => {
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
const token = `E2E_RECONNECT_${Date.now()}`
await gotoSession()
const term = await open(page)
const id = await term.getAttribute("data-pty-id")
if (!id) throw new Error("Active terminal missing data-pty-id")
const prev = await terminalConnects(page, { term })
await runTerminal(page, {
term,
cmd: `export ${name}=${token}; echo ${token}`,
token,
})
await disconnectTerminal(page, { term })
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
await runTerminal(page, {
term,
cmd: `echo $${name}`,
token,
timeout: 15_000,
})
})
})

View File

@@ -1,5 +1,4 @@
import type { Page } from "@playwright/test"
import { runTerminal, waitTerminalReady } from "../actions"
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey, workspacePersistKey } from "../utils"
@@ -18,7 +17,16 @@ async function open(page: Page) {
const terminal = page.locator(terminalSelector)
const visible = await terminal.isVisible().catch(() => false)
if (!visible) await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal })
await expect(terminal).toBeVisible()
await expect(terminal.locator("textarea")).toHaveCount(1)
}
async function run(page: Page, cmd: string) {
const terminal = page.locator(terminalSelector)
await expect(terminal).toBeVisible()
await terminal.click()
await page.keyboard.type(cmd)
await page.keyboard.press("Enter")
}
async function store(page: Page, key: string) {
@@ -48,16 +56,15 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
await gotoSession()
await open(page)
await runTerminal(page, { cmd: `echo ${one}`, token: one })
await run(page, `echo ${one}`)
await page.getByRole("button", { name: /new terminal/i }).click()
await expect(tabs).toHaveCount(2)
await runTerminal(page, { cmd: `echo ${two}`, token: two })
await run(page, `echo ${two}`)
await first.click()
await expect(first).toHaveAttribute("aria-selected", "true")
await expect
.poll(
async () => {
@@ -69,7 +76,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
second: second.includes(two),
}
},
{ timeout: 5_000 },
{ timeout: 30_000 },
)
.toEqual({ first: false, second: true })
@@ -86,7 +93,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
second: second.includes(two),
}
},
{ timeout: 5_000 },
{ timeout: 30_000 },
)
.toEqual({ first: true, second: false })
})

View File

@@ -1,5 +1,4 @@
import { test, expect } from "../fixtures"
import { waitTerminalReady } from "../actions"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
@@ -14,5 +13,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
}
await page.keyboard.press(terminalToggleKey)
await waitTerminalReady(page, { term: terminal })
await expect(terminal).toBeVisible()
})

View File

@@ -2,8 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts", "../src/testing/terminal.ts"]
"include": ["./**/*.ts"]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.25",
"version": "1.2.24",
"description": "",
"type": "module",
"exports": {
@@ -45,8 +45,8 @@
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
@@ -56,7 +56,6 @@
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "4.0.0-beta.31",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",

View File

@@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
@@ -18,7 +17,6 @@ export default defineConfig({
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,

View File

@@ -73,7 +73,6 @@ const serverEnv = {
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string>
const runnerEnv = {

View File

@@ -1,30 +1,14 @@
import "@/index.css"
import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type Duration, Effect } from "effect"
import {
type Component,
createMemo,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
lazy,
onCleanup,
type ParentProps,
Show,
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
@@ -38,13 +22,13 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { Dynamic } from "solid-js/web"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -68,7 +52,7 @@ const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
}
declare global {
@@ -78,9 +62,6 @@ declare global {
deepLinks?: string[]
wsl?: boolean
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
}
}
}
@@ -134,11 +115,7 @@ export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
}}
>
<ThemeProvider>
<LanguageProvider>
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
@@ -155,126 +132,24 @@ export function AppBaseProviders(props: ParentProps) {
)
}
const effectMinDuration =
(duration: Duration.Input) =>
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
function ServerKey(props: ParentProps) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
props.disableHealthCheck
? true
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
return (
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const language = useLanguage()
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
{unreachable()[0]}
<span class="text-text-strong font-medium">{name()}</span>
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
@@ -289,7 +164,7 @@ export function AppInterface(props: {
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ConnectionGate>
</ServerKey>
</ServerProvider>
)
}

View File

@@ -2,7 +2,6 @@ import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
type Mem = Performance & {
memory?: {
@@ -28,17 +27,17 @@ type Obs = PerformanceObserverInit & {
const span = 5000
const ms = (n?: number, d = 0) => {
if (n === undefined || Number.isNaN(n)) return
if (n === undefined || Number.isNaN(n)) return "n/a"
return `${n.toFixed(d)}ms`
}
const time = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
if (n === undefined || Number.isNaN(n)) return "n/a"
return `${Math.round(n)}`
}
const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
if (n === undefined || Number.isNaN(n)) return "n/a"
const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
}
@@ -75,7 +74,6 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
}
export function DebugBar() {
const language = useLanguage()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
@@ -100,15 +98,14 @@ export function DebugBar() {
},
})
const na = () => language.t("debugBar.na")
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => {
const value = heap()
if (value === undefined) return na()
if (value === undefined) return "n/a"
return `${Math.round(value * 100)}%`
}
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
let prev = ""
let start = 0
@@ -362,7 +359,7 @@ export function DebugBar() {
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
aria-label="Development performance diagnostics"
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
@@ -371,70 +368,67 @@ export function DebugBar() {
>
<div class="grid grid-cols-5 gap-px font-mono">
<Cell
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
label="NAV"
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
/>
<Cell
label={language.t("debugBar.fps.label")}
tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
label="FPS"
tip="Rolling frames per second over the last 5 seconds."
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
/>
<Cell
label={language.t("debugBar.frame.label")}
tip={language.t("debugBar.frame.tip")}
value={time(state.gap) ?? na()}
label="FRM"
tip="Worst frame time over the last 5 seconds."
value={time(state.gap)}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
/>
<Cell
label={language.t("debugBar.jank.label")}
tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? na() : `${state.jank}`}
label="JNK"
tip="Frames over 32ms in the last 5 seconds."
value={state.jank === undefined ? "n/a" : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
/>
<Cell
label={language.t("debugBar.long.label")}
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
label="LNG"
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
/>
<Cell
label={language.t("debugBar.delay.label")}
tip={language.t("debugBar.delay.tip")}
value={time(state.delay) ?? na()}
label="DLY"
tip="Worst observed input delay in the last 5 seconds."
value={time(state.delay)}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
/>
<Cell
label={language.t("debugBar.inp.label")}
tip={language.t("debugBar.inp.tip")}
value={time(state.inp) ?? na()}
label="INP"
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
value={time(state.inp)}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
/>
<Cell
label={language.t("debugBar.cls.label")}
tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
label="CLS"
tip="Cumulative layout shift for the current app lifetime."
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
/>
<Cell
label={language.t("debugBar.mem.label")}
label="MEM"
tip={
state.heap.used === undefined
? language.t("debugBar.mem.tipUnavailable")
: language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
? "Used JS heap vs heap limit. Chromium only."
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
}
value={heapv()}
bad={bad(heap(), 0.8)}

View File

@@ -1,159 +0,0 @@
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
export type ModelErr = {
id?: string
name?: string
}
export type HeaderErr = {
key?: string
value?: string
}
export type ModelRow = {
row: string
id: string
name: string
err: ModelErr
}
export type HeaderRow = {
row: string
key: string
value: string
err: HeaderErr
}
export type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
err: {
providerID?: string
name?: string
baseURL?: string
}
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
export function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const models = input.form.models.map((m) => {
const id = m.id.trim()
const idError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: idError, name: nameError }
})
const modelsValid = models.every((m) => !m.id && !m.name)
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headers = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headers.every((h) => !h.key && !h.value)
const headerConfig = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const err = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { err, models, headers }
return {
err,
models,
headers,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options: {
baseURL,
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
},
models: modelConfig,
},
},
}
}
let row = 0
const nextRow = () => `row-${row++}`
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })

View File

@@ -1,82 +0,0 @@
import { describe, expect, test } from "bun:test"
import { validateCustomProvider } from "./dialog-custom-provider-form"
const t = (key: string) => key
describe("validateCustomProvider", () => {
test("builds trimmed config payload", () => {
const result = validateCustomProvider({
form: {
providerID: "custom-provider",
name: " Custom Provider ",
baseURL: "https://api.example.com ",
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
headers: [
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
{ row: "h1", key: "", value: "", err: {} },
],
saving: false,
err: {},
},
t,
disabledProviders: [],
existingProviderIDs: new Set(),
})
expect(result.result).toEqual({
providerID: "custom-provider",
name: "Custom Provider",
key: undefined,
config: {
npm: "@ai-sdk/openai-compatible",
name: "Custom Provider",
env: ["CUSTOM_PROVIDER_KEY"],
options: {
baseURL: "https://api.example.com",
headers: {
"X-Test": "enabled",
},
},
models: {
"model-a": { name: "Model A" },
},
},
})
})
test("flags duplicate rows and allows reconnecting disabled providers", () => {
const result = validateCustomProvider({
form: {
providerID: "custom-provider",
name: "Provider",
baseURL: "https://api.example.com",
apiKey: "secret",
models: [
{ row: "m0", id: "model-a", name: "Model A", err: {} },
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
],
headers: [
{ row: "h0", key: "Authorization", value: "one", err: {} },
{ row: "h1", key: "authorization", value: "two", err: {} },
],
saving: false,
err: {},
},
t,
disabledProviders: ["custom-provider"],
existingProviderIDs: new Set(["custom-provider"]),
})
expect(result.result).toBeUndefined()
expect(result.err.providerID).toBeUndefined()
expect(result.models[1]).toEqual({
id: "provider.custom.error.duplicate",
name: undefined,
})
expect(result.headers[1]).toEqual({
key: "provider.custom.error.duplicate",
value: undefined,
})
})
})

View File

@@ -5,15 +5,158 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { For } from "solid-js"
import { createStore } from "solid-js/store"
import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider"
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = ReturnType<typeof useLanguage>["t"]
type ModelRow = {
id: string
name: string
}
type HeaderRow = {
key: string
value: string
}
type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
}
type FormErrors = {
providerID: string | undefined
name: string | undefined
baseURL: string | undefined
models: Array<{ id?: string; name?: string }>
headers: Array<{ key?: string; value?: string }>
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = input.form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const errors: FormErrors = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
models: modelErrors,
headers: headerErrors,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { errors }
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
errors,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
},
}
}
type Props = {
back?: "providers" | "close"
}
@@ -29,10 +172,17 @@ export function DialogCustomProvider(props: Props) {
name: "",
baseURL: "",
apiKey: "",
models: [modelRow()],
headers: [headerRow()],
models: [{ id: "", name: "" }],
headers: [{ key: "", value: "" }],
saving: false,
err: {},
})
const [errors, setErrors] = createStore<FormErrors>({
providerID: undefined,
name: undefined,
baseURL: undefined,
models: [{}],
headers: [{}],
})
const goBack = () => {
@@ -44,61 +194,25 @@ export function DialogCustomProvider(props: Props) {
}
const addModel = () => {
setForm(
"models",
produce((rows) => {
rows.push(modelRow())
}),
)
setForm("models", (v) => [...v, { id: "", name: "" }])
setErrors("models", (v) => [...v, {}])
}
const removeModel = (index: number) => {
if (form.models.length <= 1) return
setForm(
"models",
produce((rows) => {
rows.splice(index, 1)
}),
)
setForm("models", (v) => v.filter((_, i) => i !== index))
setErrors("models", (v) => v.filter((_, i) => i !== index))
}
const addHeader = () => {
setForm(
"headers",
produce((rows) => {
rows.push(headerRow())
}),
)
setForm("headers", (v) => [...v, { key: "", value: "" }])
setErrors("headers", (v) => [...v, {}])
}
const removeHeader = (index: number) => {
if (form.headers.length <= 1) return
setForm(
"headers",
produce((rows) => {
rows.splice(index, 1)
}),
)
}
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
setForm(key, value)
if (key === "apiKey") return
setForm("err", key, undefined)
}
const setModel = (index: number, key: "id" | "name", value: string) => {
batch(() => {
setForm("models", index, key, value)
setForm("models", index, "err", key, undefined)
})
}
const setHeader = (index: number, key: "key" | "value", value: string) => {
batch(() => {
setForm("headers", index, key, value)
setForm("headers", index, "err", key, undefined)
})
setForm("headers", (v) => v.filter((_, i) => i !== index))
setErrors("headers", (v) => v.filter((_, i) => i !== index))
}
const validate = () => {
@@ -108,11 +222,7 @@ export function DialogCustomProvider(props: Props) {
disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
})
batch(() => {
setForm("err", output.err)
output.models.forEach((err, index) => setForm("models", index, "err", err))
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
})
setErrors(output.errors)
return output.result
}
@@ -195,32 +305,32 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={(v) => setField("providerID", v)}
validationState={form.err.providerID ? "invalid" : undefined}
error={form.err.providerID}
onChange={(v) => setForm("providerID", v)}
validationState={errors.providerID ? "invalid" : undefined}
error={errors.providerID}
/>
<TextField
label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name}
onChange={(v) => setField("name", v)}
validationState={form.err.name ? "invalid" : undefined}
error={form.err.name}
onChange={(v) => setForm("name", v)}
validationState={errors.name ? "invalid" : undefined}
error={errors.name}
/>
<TextField
label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL}
onChange={(v) => setField("baseURL", v)}
validationState={form.err.baseURL ? "invalid" : undefined}
error={form.err.baseURL}
onChange={(v) => setForm("baseURL", v)}
validationState={errors.baseURL ? "invalid" : undefined}
error={errors.baseURL}
/>
<TextField
label={language.t("provider.custom.field.apiKey.label")}
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey}
onChange={(v) => setField("apiKey", v)}
onChange={(v) => setForm("apiKey", v)}
/>
</div>
@@ -228,16 +338,16 @@ export function DialogCustomProvider(props: Props) {
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}>
{(m, i) => (
<div class="flex gap-2 items-start" data-row={m.row}>
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextField
label={language.t("provider.custom.models.id.label")}
hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id}
onChange={(v) => setModel(i(), "id", v)}
validationState={m.err.id ? "invalid" : undefined}
error={m.err.id}
onChange={(v) => setForm("models", i(), "id", v)}
validationState={errors.models[i()]?.id ? "invalid" : undefined}
error={errors.models[i()]?.id}
/>
</div>
<div class="flex-1">
@@ -246,9 +356,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => setModel(i(), "name", v)}
validationState={m.err.name ? "invalid" : undefined}
error={m.err.name}
onChange={(v) => setForm("models", i(), "name", v)}
validationState={errors.models[i()]?.name ? "invalid" : undefined}
error={errors.models[i()]?.name}
/>
</div>
<IconButton
@@ -272,16 +382,16 @@ export function DialogCustomProvider(props: Props) {
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}>
{(h, i) => (
<div class="flex gap-2 items-start" data-row={h.row}>
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextField
label={language.t("provider.custom.headers.key.label")}
hideLabel
placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key}
onChange={(v) => setHeader(i(), "key", v)}
validationState={h.err.key ? "invalid" : undefined}
error={h.err.key}
onChange={(v) => setForm("headers", i(), "key", v)}
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
error={errors.headers[i()]?.key}
/>
</div>
<div class="flex-1">
@@ -290,9 +400,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel
placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value}
onChange={(v) => setHeader(i(), "value", v)}
validationState={h.err.value ? "invalid" : undefined}
error={h.err.value}
onChange={(v) => setForm("headers", i(), "value", v)}
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
error={errors.headers[i()]?.value}
/>
</div>
<IconButton

View File

@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useNavigate } from "@solidjs/router"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk"
@@ -14,8 +14,6 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
@@ -134,14 +132,9 @@ function createFileEntries(props: {
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const tabState = createSessionTabs({
tabs: props.tabs,
pathFromTab: props.file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
})
const recent = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const all = props.tabs().all()
const active = props.tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
@@ -266,11 +259,14 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const params = useParams()
const navigate = useNavigate()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const { params, tabs, view } = useSessionLayout()
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 commandEntries = createCommandEntries({ filesOnly, command, language })
@@ -426,7 +422,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
</Show>
</div>
</Match>

View File

@@ -14,9 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface ServerFormProps {
value: string
@@ -43,15 +41,13 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
} catch (err) {
showRequestError(language, err)
return null
@@ -60,22 +56,20 @@ function useDefaultServer() {
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const setDefault = async (url: string | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
await platform.setDefaultServerUrl?.(url)
defaultUrlActions.mutate(url)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey, canDefault, setDefault }
return { defaultUrl, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
function useServerPreview(fetcher: typeof fetch) {
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
@@ -98,7 +92,7 @@ function useServerPreview() {
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
const result = await checkServerHealth(http, fetcher)
setStatus(result.healthy)
}
@@ -121,7 +115,7 @@ function ServerForm(props: ServerFormProps) {
return (
<div class="px-5">
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField
type="text"
@@ -149,7 +143,7 @@ function ServerForm(props: ServerFormProps) {
<TextField
type="text"
label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
placeholder="username"
value={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
@@ -158,7 +152,7 @@ function ServerForm(props: ServerFormProps) {
<TextField
type="password"
label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
placeholder="password"
value={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
@@ -176,15 +170,15 @@ export function DialogSelectServer() {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
url: "",
name: "",
username: DEFAULT_USERNAME,
username: "",
password: "",
adding: false,
error: "",
@@ -207,7 +201,7 @@ export function DialogSelectServer() {
setStore("addServer", {
url: "",
name: "",
username: DEFAULT_USERNAME,
username: "",
password: "",
adding: false,
error: "",
@@ -270,7 +264,7 @@ export function DialogSelectServer() {
const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all(
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}),
)
setStore("status", reconcile(results))
@@ -368,9 +362,9 @@ export function DialogSelectServer() {
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.username) conn.http.username = store.addServer.username
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
const result = await checkServerHealth(conn.http, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
@@ -410,7 +404,7 @@ export function DialogSelectServer() {
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
const result = await checkServerHealth(conn.http, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
@@ -447,7 +441,7 @@ export function DialogSelectServer() {
showForm: true,
url: "",
name: "",
username: DEFAULT_USERNAME,
username: "",
password: "",
error: "",
status: undefined,
@@ -500,8 +494,8 @@ export function DialogSelectServer() {
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
platform.setDefaultServer?.(null)
if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null)
}
}
@@ -542,7 +536,7 @@ export function DialogSelectServer() {
if (x) select(x)
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
>
{(i) => {
const key = ServerConnection.key(i)
@@ -557,7 +551,7 @@ export function DialogSelectServer() {
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<Show when={defaultUrl() === i.http.url}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -590,14 +584,14 @@ export function DialogSelectServer() {
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}

View File

@@ -17,6 +17,7 @@ import {
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
@@ -26,6 +27,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { RadioGroup } from "@opencode-ai/ui/radio-group"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
@@ -35,11 +37,8 @@ import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
@@ -49,7 +48,7 @@ import {
type PromptHistoryStoredEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
import { createPromptSubmit } from "./prompt-input/submit"
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
@@ -62,11 +61,6 @@ interface PromptInputProps {
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
shouldQueue?: () => boolean
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
}
@@ -108,21 +102,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const prompt = usePrompt()
const layout = useLayout()
const comments = useComments()
const params = useParams()
const dialog = useDialog()
const providers = useProviders()
const command = useCommand()
const permission = usePermission()
const language = useLanguage()
const platform = usePlatform()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
const mirror = { input: false }
const inset = 52
const space = `${inset}px`
const inset = 44
const scrollCursorIntoView = () => {
const container = scrollRef
@@ -157,18 +150,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const queueScroll = (count = 2) => {
requestAnimationFrame(() => {
scrollCursorIntoView()
if (count > 1) queueScroll(count - 1)
})
const queueScroll = () => {
requestAnimationFrame(scrollCursorIntoView)
}
const activeFileTab = createSessionTabs({
tabs,
pathFromTab: files.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
}).activeFileTab
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
@@ -221,7 +209,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const recent = createMemo(() => {
const all = tabs().all()
const active = activeFileTab()
const active = tabs().active()
const order = active ? [active, ...all.filter((x) => x !== active)] : all
const seen = new Set<string>()
const paths: string[] = []
@@ -267,15 +255,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
transform: `scale(${0.95 + value * 0.05})`,
filter: `blur(${(1 - value) * 2}px)`,
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
})
const buttons = createMemo(() => motion(buttonsSpring()))
const shell = createMemo(() => motion(1 - buttonsSpring()))
const control = createMemo(() => ({ height: "28px", ...buttons() }))
const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
@@ -511,18 +490,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setComposing(false)
}
const handleCompositionStart = () => {
setComposing(true)
}
const handleCompositionEnd = () => {
setComposing(false)
requestAnimationFrame(() => {
if (composing()) return
reconcile(prompt.current().filter((part) => part.type !== "image"))
})
}
const agentList = createMemo(() =>
sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
@@ -713,27 +680,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const reconcile = (input: Prompt) => {
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(input)
return
}
const dom = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
renderEditorWithCursor(input)
}
createEffect(
on(
() => prompt.current(),
(parts) => {
if (composing()) return
reconcile(parts.filter((part) => part.type !== "image"))
(currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image")
if (mirror.input) {
mirror.input = false
if (isNormalizedEditor()) return
renderEditorWithCursor(inputParts)
return
}
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
renderEditorWithCursor(inputParts)
},
),
)
@@ -957,45 +921,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setCurrentHistory("entries", next)
}
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
}
for (const item of edit.context) {
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
})
}
setStore("mode", "normal")
setStore("popover", null)
setStore("historyIndex", -1)
setStore("savedPrompt", null)
prompt.set(edit.prompt, promptLength(edit.prompt))
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, promptLength(edit.prompt))
queueScroll()
})
props.onEditLoaded?.()
},
{ defer: true },
),
)
const navigateHistory = (direction: "up" | "down") => {
const result = navigatePromptHistory({
direction,
@@ -1012,7 +937,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef,
isFocused,
isDialogActive: () => !!dialog.active,
@@ -1050,9 +975,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setPopover: (popover) => setStore("popover", popover),
newSessionWorktree: () => props.newSessionWorktree,
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
shouldQueue: props.shouldQueue,
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
})
@@ -1252,7 +1174,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
onRemove={removeImageAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
@@ -1270,11 +1192,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef?.focus()
}}
>
<div
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
ref={(el) => (scrollRef = el)}
style={{ "scroll-padding-bottom": space }}
>
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
@@ -1290,40 +1208,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
spellcheck={store.mode === "normal"}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
style={{ "padding-bottom": space }}
/>
<Show when={!prompt.dirty()}>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
style={{ "padding-bottom": space }}
>
{placeholder()}
</div>
</Show>
</div>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 bottom-0"
style={{
height: space,
background:
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
}}
/>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
@@ -1332,7 +1238,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
if (file) void addAttachment(file)
if (file) addImageAttachment(file)
e.currentTarget.value = ""
}}
/>
@@ -1354,7 +1260,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
variant="ghost"
class="size-8 p-0"
style={buttons()}
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1392,7 +1302,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={buttons()}
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1450,7 +1364,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{
padding: "0 4px 0 8px",
...shell(),
opacity: 1 - buttonsSpring(),
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
filter: `blur(${buttonsSpring() * 2}px)`,
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
}}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
@@ -1470,7 +1387,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={control()}
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
variant="ghost"
/>
</TooltipKeybind>
@@ -1488,7 +1411,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={control()}
style={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
@@ -1517,7 +1446,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
style: {
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
},
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
@@ -1549,12 +1484,48 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={control()}
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
variant="ghost"
/>
</TooltipKeybind>
</div>
</div>
<div class="shrink-0">
<RadioGroup
options={["shell", "normal"] as const}
current={store.mode}
value={(mode) => mode}
label={(mode) => (
<TooltipKeybind
placement="top"
gutter={4}
openDelay={2000}
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
class="size-full flex items-center justify-center"
>
<Icon
name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]"
classList={{
"text-icon-strong-base": store.mode === mode,
"text-icon-weak": store.mode !== mode,
}}
/>
</TooltipKeybind>
)}
onSelect={(mode) => mode && setMode(mode)}
fill
pad="none"
class="w-[68px]"
/>
</div>
</div>
</DockTray>
</Show>

View File

@@ -1,24 +0,0 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime } from "./files"
describe("attachmentMime", () => {
test("keeps PDFs when the browser reports the mime", async () => {
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
expect(await attachmentMime(file)).toBe("application/pdf")
})
test("normalizes structured text types to text/plain", async () => {
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("accepts text files even with a misleading browser mime", async () => {
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
expect(await attachmentMime(file)).toBe("text/plain")
})
test("rejects binary files", async () => {
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
expect(await attachmentMime(file)).toBeUndefined()
})
})

View File

@@ -4,27 +4,12 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context
import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { attachmentMime } from "./files"
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
const LARGE_PASTE_CHARS = 8000
const LARGE_PASTE_BREAKS = 120
function dataUrl(file: File, mime: string) {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.addEventListener("error", () => resolve(""))
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : ""
const idx = value.indexOf(",")
if (idx === -1) {
resolve(value)
return
}
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
})
reader.readAsDataURL(file)
})
}
function largePaste(text: string) {
if (text.length >= LARGE_PASTE_CHARS) return true
let breaks = 0
@@ -50,41 +35,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt()
const language = useLanguage()
const warn = () => {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
const addImageAttachment = async (file: File) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
const reader = new FileReader()
reader.onload = () => {
const editor = input.editor()
if (!editor) return
const dataUrl = reader.result as string
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
mime: file.type,
dataUrl,
}
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursorPosition)
}
reader.readAsDataURL(file)
}
const add = async (file: File, toast = true) => {
const mime = await attachmentMime(file)
if (!mime) {
if (toast) warn()
return false
}
const editor = input.editor()
if (!editor) return false
const url = await dataUrl(file, mime)
if (!url) return false
const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
mime,
dataUrl: url,
}
const cursor = prompt.cursor() ?? getCursorPosition(editor)
prompt.set([...prompt.current(), attachment], cursor)
return true
}
const addAttachment = (file: File) => add(file)
const removeAttachment = (id: string) => {
const removeImageAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor())
@@ -100,16 +72,21 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const items = Array.from(clipboardData.items)
const fileItems = items.filter((item) => item.kind === "file")
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
for (const item of imageItems) {
const file = item.getAsFile()
if (file) await addImageAttachment(file)
}
return
}
if (fileItems.length > 0) {
let found = false
for (const item of fileItems) {
const file = item.getAsFile()
if (!file) continue
const ok = await add(file, false)
if (ok) found = true
}
if (!found) warn()
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
return
}
@@ -119,7 +96,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file) {
await addAttachment(file)
await addImageAttachment(file)
return
}
}
@@ -176,12 +153,11 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files
if (!dropped) return
let found = false
for (const file of Array.from(dropped)) {
const ok = await add(file, false)
if (ok) found = true
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
await addImageAttachment(file)
}
}
if (!found && dropped.length > 0) warn()
}
onMount(() => {
@@ -197,8 +173,8 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
})
return {
addAttachment,
removeAttachment,
addImageAttachment,
removeImageAttachment,
handlePaste,
}
}

View File

@@ -1,119 +0,0 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
const IMAGE_EXTS = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
])
const TEXT_MIMES = new Set([
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
])
export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
"application/pdf",
"text/*",
"application/json",
"application/ld+json",
"application/toml",
"application/x-toml",
"application/x-yaml",
"application/xml",
"application/yaml",
".c",
".cc",
".cjs",
".conf",
".cpp",
".css",
".csv",
".cts",
".env",
".go",
".gql",
".graphql",
".h",
".hh",
".hpp",
".htm",
".html",
".ini",
".java",
".js",
".json",
".jsx",
".log",
".md",
".mdx",
".mjs",
".mts",
".py",
".rb",
".rs",
".sass",
".scss",
".sh",
".sql",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
".zsh",
]
const SAMPLE = 4096
function kind(type: string) {
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
}
function ext(name: string) {
const idx = name.lastIndexOf(".")
if (idx === -1) return ""
return name.slice(idx + 1).toLowerCase()
}
function textMime(type: string) {
if (!type) return false
if (type.startsWith("text/")) return true
if (TEXT_MIMES.has(type)) return true
if (type.endsWith("+json")) return true
return type.endsWith("+xml")
}
function textBytes(bytes: Uint8Array) {
if (bytes.length === 0) return true
let count = 0
for (const byte of bytes) {
if (byte === 0) return false
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
}
return count / bytes.length <= 0.3
}
export async function attachmentMime(file: File) {
const type = kind(file.type)
if (IMAGE_MIMES.has(type)) return type
if (type === "application/pdf") return type
const suffix = ext(file.name)
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
if ((!type || type === "application/octet-stream") && fallback) return fallback
if (textMime(type)) return "text/plain"
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
if (!textBytes(bytes)) return
return "text/plain"
}

View File

@@ -7,16 +7,12 @@ const createdClients: string[] = []
const createdSessions: string[] = []
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
sessionID?: string
message: {
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: string[] = []
const syncedDirectories: string[] = []
@@ -32,12 +28,7 @@ const clientFor = (directory: string) => {
session: {
create: async () => {
createdSessions.push(directory)
return {
data: {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
},
}
return { data: { id: `session-${createdSessions.length}` } }
},
shell: async () => {
sentShell.push(directory)
@@ -138,16 +129,9 @@ beforeAll(async () => {
session: {
optimistic: {
add: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
}) => {
optimistic.push(value)
optimisticSeeded.push(
!!value.directory &&
!!value.sessionID &&
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
)
},
remove: () => undefined,
},
@@ -160,21 +144,7 @@ beforeAll(async () => {
useGlobalSync: () => ({
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
return [{}, () => undefined]
},
}),
}))
@@ -200,13 +170,11 @@ beforeEach(() => {
createdSessions.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
params = {}
sentShell.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
describe("prompt submit worktree selection", () => {
@@ -239,7 +207,7 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
})
test("applies auto-accept to newly created sessions", async () => {
@@ -303,32 +271,4 @@ describe("prompt submit worktree selection", () => {
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(optimisticSeeded).toEqual([true])
})
})

View File

@@ -1,7 +1,6 @@
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { Binary } from "@opencode-ai/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
@@ -10,7 +9,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { Identifier } from "@/utils/id"
@@ -26,145 +25,6 @@ type PendingPrompt = {
const pending = new Map<string, PendingPrompt>()
export type FollowupDraft = {
sessionID: string
sessionDirectory: string
prompt: Prompt
context: (ContextItem & { key: string })[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
type FollowupSendInput = {
client: ReturnType<typeof useSDK>["client"]
globalSync: ReturnType<typeof useGlobalSync>
sync: ReturnType<typeof useSync>
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
}
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
const setBusy = () => {
if (!input.optimisticBusy) return
setStore("session_status", input.draft.sessionID, { type: "busy" })
}
const setIdle = () => {
if (!input.optimisticBusy) return
setStore("session_status", input.draft.sessionID, { type: "idle" })
}
const wait = async () => {
const ok = await input.before?.()
if (ok === false) return false
return true
}
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
setBusy()
try {
if (!(await wait())) {
setIdle()
return false
}
await input.client.session.command({
sessionID: input.draft.sessionID,
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
variant: input.draft.variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
return true
} catch (err) {
setIdle()
throw err
}
}
const messageID = input.messageID ?? Identifier.ascending("message")
const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt,
context: input.draft.context,
images,
text,
sessionID: input.draft.sessionID,
messageID,
sessionDirectory: input.draft.sessionDirectory,
})
const message: Message = {
id: messageID,
sessionID: input.draft.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
}
const add = () =>
input.sync.session.optimistic.add({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
message,
parts: optimisticParts,
})
const remove = () =>
input.sync.session.optimistic.remove({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
setBusy()
add()
try {
if (!(await wait())) {
setIdle()
remove()
return false
}
await input.client.session.promptAsync({
sessionID: input.draft.sessionID,
agent: input.draft.agent,
model: input.draft.model,
messageID,
parts: requestParts,
variant: input.draft.variant,
})
return true
} catch (err) {
setIdle()
remove()
throw err
}
}
type PromptSubmitInput = {
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
@@ -181,9 +41,6 @@ type PromptSubmitInput = {
setPopover: (popover: "at" | "slash" | null) => void
newSessionWorktree?: Accessor<string | undefined>
onNewSessionWorktreeReset?: () => void
shouldQueue?: Accessor<boolean>
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
}
@@ -225,8 +82,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const [, setStore] = globalSync.child(sdk.directory)
setStore("todo", sessionID, [])
input.onAbort?.()
const queued = pending.get(sessionID)
if (queued) {
queued.abort.abort()
@@ -261,26 +116,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const clearContext = () => {
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
}
}
const seed = (dir: string, info: Session) => {
const [, setStore] = globalSync.child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
next[result.index] = info
return next
}
next.splice(result.index, 0, info)
return next
})
}
const handleSubmit = async (event: Event) => {
event.preventDefault()
@@ -356,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info()
if (!session && isNewSession) {
const created = await client.session
session = await client.session
.create()
.then((x) => x.data ?? undefined)
.catch((err) => {
@@ -366,9 +201,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
return undefined
})
if (created) {
seed(sessionDirectory, created)
session = created
if (session) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
@@ -382,22 +215,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
input.onSubmit?.()
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
const context = prompt.context.items().slice()
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => {
prompt.reset()
@@ -418,15 +243,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext()
clearInput()
return
}
input.onSubmit?.()
if (mode === "shell") {
clearInput()
client.session
@@ -479,19 +295,48 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const context = prompt.context.items().slice()
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
const messageID = Identifier.ascending("message")
const { requestParts, optimisticParts } = buildRequestParts({
prompt: currentPrompt,
context,
images,
text,
sessionID: session.id,
messageID,
sessionDirectory,
})
const optimisticMessage: Message = {
id: messageID,
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent,
model,
variant,
}
const addOptimisticMessage = () =>
sync.session.optimistic.add({
directory: sessionDirectory,
sessionID: session.id,
message: optimisticMessage,
parts: optimisticParts,
})
const removeOptimisticMessage = () =>
sync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
removeCommentItems(commentItems)
clearInput()
addOptimisticMessage()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
@@ -548,15 +393,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return true
}
void sendFollowupDraft({
client,
sync,
globalSync,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree,
}).catch((err) => {
const send = async () => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.promptAsync({
sessionID: session.id,
agent,
model,
messageID,
parts: requestParts,
variant,
})
}
void send().catch((err) => {
pending.delete(session.id)
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })

View File

@@ -10,7 +10,6 @@ import {
type ParentProps,
Show,
} from "solid-js"
import { useLanguage } from "@/context/language"
import { type ServerConnection, serverName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
@@ -26,7 +25,6 @@ interface ServerRowProps extends ParentProps {
}
export function ServerRow(props: ServerRowProps) {
const language = useLanguage()
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
@@ -67,26 +65,22 @@ export function ServerRow(props: ServerRowProps) {
return (
<Tooltip
class="flex-1 min-w-0"
class="flex-1"
value={tooltipValue()}
contentStyle={{ "max-width": "none", "white-space": "nowrap" }}
placement="top-start"
inactive={!truncated() && !props.conn.displayName}
>
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
<div class="flex flex-col items-start min-w-0 w-full">
<div class="flex flex-row items-center gap-2 min-w-0 w-full">
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
<div class="flex flex-col items-start">
<div class="flex flex-row items-center gap-2">
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
{name()}
</span>
<Show
when={badge()}
fallback={
<Show when={props.status?.version}>
<span
ref={versionRef}
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
v{props.status?.version}
</span>
</Show>
@@ -102,7 +96,7 @@ export function ServerRow(props: ServerRowProps) {
{conn().http.username ? (
<span class="text-text-weak">{conn().http.username}</span>
) : (
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
<span class="text-text-weaker">no username</span>
)}
</span>
{conn().http.password && <span class="text-text-weak"></span>}

View File

@@ -2,14 +2,12 @@ import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -29,17 +27,14 @@ function openSessionContext(args: {
export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const file = useFile()
const params = useParams()
const layout = useLayout()
const language = useLanguage()
const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button")
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
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(
@@ -59,7 +54,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
if (tabState.activeTab() === "context") {
if (tabs().active() === "context") {
tabs().close("context")
return
}

View File

@@ -1,6 +1,8 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
@@ -12,7 +14,6 @@ import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
@@ -90,10 +91,13 @@ const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const params = useParams()
const sync = useSync()
const layout = useLayout()
const language = useLanguage()
const { params, view } = useSessionLayout()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(

View File

@@ -4,21 +4,25 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Popover } from "@opencode-ai/ui/popover"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover"
@@ -46,68 +50,74 @@ type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [
{
id: "vscode",
label: "session.header.open.app.vscode",
label: "VS Code",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{ 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: "session.header.open.app.antigravity",
label: "Antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{ 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: "warp", label: "Warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "session.header.open.app.androidStudio",
label: "Android Studio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
label: "Sublime Text",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{ 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: "powershell",
label: "session.header.open.app.powershell",
label: "PowerShell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
label: "Sublime Text",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const LINUX_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{ 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: "sublime-text",
label: "session.header.open.app.sublimeText",
label: "Sublime Text",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
type OpenIcon = OpenApp | "file-explorer"
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
@@ -126,14 +136,102 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
})
}
function useSessionShare(args: {
globalSDK: ReturnType<typeof useGlobalSDK>
currentSession: () =>
| {
share?: {
url?: string
}
}
| undefined
sessionID: () => string | undefined
projectDirectory: () => string
platform: ReturnType<typeof usePlatform>
}) {
const [state, setState] = createStore({
share: false,
unshare: false,
copied: false,
timer: undefined as number | undefined,
})
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
})
onCleanup(() => {
if (state.timer) window.clearTimeout(state.timer)
})
const shareSession = () => {
const sessionID = args.sessionID()
if (!sessionID || state.share) return
setState("share", true)
args.globalSDK.client.session
.share({ sessionID, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
const unshareSession = () => {
const sessionID = args.sessionID()
if (!sessionID || state.unshare) return
setState("unshare", true)
args.globalSDK.client.session
.unshare({ sessionID, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
const copyLink = (onError: (error: unknown) => void) => {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch(onError)
}
const viewShare = () => {
const url = shareUrl()
if (!url) return
args.platform.openLink(url)
}
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
}
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
const server = useServer()
const sync = useSync()
const platform = usePlatform()
const language = useLanguage()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
@@ -147,6 +245,12 @@ export function SessionHeader() {
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const showShare = createMemo(() => shareEnabled() && !!params.id)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const os = createMemo(() => detectOS(platform))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
@@ -160,9 +264,9 @@ export function SessionHeader() {
})
const fileManager = createMemo(() => {
if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
return { label: "session.header.open.fileManager", icon: "finder" as const }
if (os() === "macos") return { label: "Finder", icon: "finder" as const }
if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const }
return { label: "File Manager", icon: "finder" as const }
})
createEffect(() => {
@@ -178,7 +282,10 @@ export function SessionHeader() {
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
.then((ok) => {
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
return [app.id, ok] as const
}),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
@@ -187,10 +294,8 @@ export function SessionHeader() {
const options = createMemo(() => {
return [
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
{ id: "finder", label: fileManager().label, icon: fileManager().icon },
...apps().filter((app) => exists[app.id]),
] as const
})
@@ -256,6 +361,14 @@ export function SessionHeader() {
.catch((err: unknown) => showRequestError(language, err))
}
const share = useSessionShare({
globalSDK,
currentSession,
sessionID: () => params.id,
projectDirectory,
platform,
})
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
@@ -283,9 +396,7 @@ export function SessionHeader() {
<Show when={hotkey()}>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()}
</Keybind>
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
)}
</Show>
</Button>
@@ -296,6 +407,7 @@ export function SessionHeader() {
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-2">
<StatusPopover />
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
@@ -320,7 +432,7 @@ export function SessionHeader() {
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
@@ -328,13 +440,17 @@ export function SessionHeader() {
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<div class="flex size-5 shrink-0 items-center justify-center">
<Show
when={opening()}
fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
>
<Spinner class="size-3.5 text-icon-base" />
</Show>
</div>
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
</Button>
<div class="self-stretch w-px bg-border-weak-base" />
<DropdownMenu
gutter={4}
placement="bottom-end"
@@ -346,20 +462,17 @@ export function SessionHeader() {
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
@@ -376,8 +489,8 @@ export function SessionHeader() {
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
@@ -410,10 +523,113 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<Show when={showShare()}>
<div class="flex items-center">
<Popover
title={language.t("session.share.popover.title")}
description={
share.shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
gutter={4}
placement="bottom-end"
shift={-64}
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
triggerAs={Button}
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
classList: {
"rounded-r-none": share.shareUrl() !== undefined,
"border-r-0": share.shareUrl() !== undefined,
},
style: { scale: 1 },
}}
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
>
<div class="flex flex-col gap-2">
<Show
when={share.shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={share.shareSession}
disabled={share.state.share}
>
{share.state.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
</div>
}
>
<div class="flex flex-col gap-2">
<TextField
value={share.shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={share.unshareSession}
disabled={share.state.unshare}
>
{share.state.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={share.viewShare}
disabled={share.state.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</Popover>
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
<Tooltip
value={
share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
placement="top"
gutter={8}
>
<IconButton
icon={share.state.copied ? "check" : "link"}
variant="ghost"
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={share.state.unshare}
aria-label={
share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
/>
</Tooltip>
</Show>
</div>
</Show>
<div class="flex items-center gap-1">
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
@@ -426,7 +642,23 @@ export function SessionHeader() {
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
@@ -443,7 +675,23 @@ export function SessionHeader() {
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>

View File

@@ -13,6 +13,7 @@ const ROOT_CLASS = "size-full flex flex-col"
interface NewSessionViewProps {
worktree: string
onWorktreeChange: (value: string) => void
}
export function NewSessionView(props: NewSessionViewProps) {

View File

@@ -6,10 +6,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLanguage } from "@/context/language"
import { focusTerminalById } from "@/pages/session/helpers"
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
const terminal = useTerminal()
@@ -28,7 +26,11 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
if (!Number.isFinite(number) || number <= 0) return false
return isDefaultTerminalTitle(props.terminal.title, number)
const match = props.terminal.title.match(/^Terminal (\d+)$/)
if (!match) return false
const parsed = Number(match[1])
if (!Number.isFinite(parsed) || parsed <= 0) return false
return parsed === number
}
const label = () => {
@@ -51,8 +53,21 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
const focus = () => {
if (store.editing) return
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id)
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
if (!element) return
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
if (textarea) {
textarea.focus()
return
}
element.focus()
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
}
const edit = (e?: Event) => {

View File

@@ -0,0 +1,16 @@
import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsAgents: Component = () => {
// TODO: Replace this placeholder with full agents settings controls.
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsCommands: Component = () => {
// TODO: Replace this placeholder with full commands settings controls.
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
</div>
</div>
)
}

View File

@@ -12,7 +12,6 @@ import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { SettingsList } from "./settings-list"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
@@ -114,11 +113,6 @@ export const SettingsGeneral: Component = () => {
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
@@ -176,9 +170,11 @@ export const SettingsGeneral: Component = () => {
triggerVariant: "settings" as const,
})
const GeneralSection = () => (
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
@@ -197,70 +193,8 @@ export const SettingsGeneral: Component = () => {
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
>
<Select
data-action="settings-followup"
options={followupOptions()}
current={followupOptions().find((o) => o.value === settings.general.followup())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && settings.general.setFollowup(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsList>
</div>
)
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
title={language.t("settings.general.row.appearance.title")}
description={language.t("settings.general.row.appearance.description")}
>
<Select
data-action="settings-color-scheme"
@@ -277,7 +211,6 @@ export const SettingsGeneral: Component = () => {
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "220px" }}
/>
</SettingsRow>
@@ -334,7 +267,51 @@ export const SettingsGeneral: Component = () => {
)}
</Select>
</SettingsRow>
</SettingsList>
</div>
</div>
)
const FeedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
)
@@ -342,7 +319,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<SettingsList>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
@@ -378,7 +355,7 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
</SettingsList>
</div>
</div>
)
@@ -386,7 +363,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<SettingsList>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
@@ -431,7 +408,7 @@ export const SettingsGeneral: Component = () => {
)}
/>
</SettingsRow>
</SettingsList>
</div>
</div>
)
@@ -439,7 +416,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<SettingsList>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
@@ -475,7 +452,7 @@ export const SettingsGeneral: Component = () => {
: language.t("settings.updates.action.checkNow")}
</Button>
</SettingsRow>
</SettingsList>
</div>
</div>
)
@@ -488,10 +465,10 @@ export const SettingsGeneral: Component = () => {
</div>
<div class="flex flex-col gap-8 w-full">
<GeneralSection />
<AppearanceSection />
<FeedSection />
<NotificationsSection />
<SoundsSection />
@@ -505,7 +482,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
<SettingsList>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.desktop.wsl.title")}
description={language.t("settings.desktop.wsl.description")}
@@ -518,7 +495,7 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
</SettingsList>
</div>
</div>
)
}}
@@ -538,7 +515,7 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={
<div class="flex items-center gap-2">
@@ -556,7 +533,7 @@ export const SettingsGeneral: Component = () => {
<Switch checked={value() === "wayland"} onChange={onChange} />
</div>
</SettingsRow>
</SettingsList>
</div>
</div>
)
}}
@@ -574,12 +551,12 @@ interface SettingsRowProps {
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
<div class="flex-shrink-0">{props.children}</div>
</div>
)
}

View File

@@ -9,7 +9,6 @@ import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SettingsList } from "./settings-list"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -239,7 +238,7 @@ function useKeyCapture(input: {
showToast({
title: input.language.t("settings.shortcuts.conflict.title"),
description: input.language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next, input.language.t),
keybind: formatKeybind(next),
titles: [...conflicts.values()].join(", "),
}),
})
@@ -407,7 +406,7 @@ export const SettingsKeybinds: Component = () => {
<Show when={(filtered().get(group) ?? []).length > 0}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
<SettingsList>
<div class="bg-surface-raised-base px-4 rounded-lg">
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
@@ -433,7 +432,7 @@ export const SettingsKeybinds: Component = () => {
</div>
)}
</For>
</SettingsList>
</div>
</div>
</Show>
)}

View File

@@ -1,5 +0,0 @@
import { type Component, type JSX } from "solid-js"
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
}

View File

@@ -0,0 +1,16 @@
import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsMcp: Component = () => {
// TODO: Replace this placeholder with full MCP settings controls.
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
</div>
</div>
)
}

View File

@@ -8,7 +8,6 @@ import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@@ -101,7 +100,7 @@ export const SettingsModels: Component = () => {
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
</div>
<SettingsList>
<div class="bg-surface-raised-base px-4 rounded-lg">
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
@@ -125,7 +124,7 @@ export const SettingsModels: Component = () => {
)
}}
</For>
</SettingsList>
</div>
</div>
)}
</For>

View File

@@ -0,0 +1,230 @@
import { Select } from "@opencode-ai/ui/select"
import { showToast } from "@opencode-ai/ui/toast"
import { Component, For, createMemo, type JSX } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
type PermissionAction = "allow" | "ask" | "deny"
type PermissionObject = Record<string, PermissionAction>
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
type PermissionMap = Record<string, PermissionValue>
type PermissionItem = {
id: string
title: string
description: string
}
const ACTIONS = [
{ value: "allow", label: "settings.permissions.action.allow" },
{ value: "ask", label: "settings.permissions.action.ask" },
{ value: "deny", label: "settings.permissions.action.deny" },
] as const
const ITEMS = [
{
id: "read",
title: "settings.permissions.tool.read.title",
description: "settings.permissions.tool.read.description",
},
{
id: "edit",
title: "settings.permissions.tool.edit.title",
description: "settings.permissions.tool.edit.description",
},
{
id: "glob",
title: "settings.permissions.tool.glob.title",
description: "settings.permissions.tool.glob.description",
},
{
id: "grep",
title: "settings.permissions.tool.grep.title",
description: "settings.permissions.tool.grep.description",
},
{
id: "list",
title: "settings.permissions.tool.list.title",
description: "settings.permissions.tool.list.description",
},
{
id: "bash",
title: "settings.permissions.tool.bash.title",
description: "settings.permissions.tool.bash.description",
},
{
id: "task",
title: "settings.permissions.tool.task.title",
description: "settings.permissions.tool.task.description",
},
{
id: "skill",
title: "settings.permissions.tool.skill.title",
description: "settings.permissions.tool.skill.description",
},
{
id: "lsp",
title: "settings.permissions.tool.lsp.title",
description: "settings.permissions.tool.lsp.description",
},
{
id: "todoread",
title: "settings.permissions.tool.todoread.title",
description: "settings.permissions.tool.todoread.description",
},
{
id: "todowrite",
title: "settings.permissions.tool.todowrite.title",
description: "settings.permissions.tool.todowrite.description",
},
{
id: "webfetch",
title: "settings.permissions.tool.webfetch.title",
description: "settings.permissions.tool.webfetch.description",
},
{
id: "websearch",
title: "settings.permissions.tool.websearch.title",
description: "settings.permissions.tool.websearch.description",
},
{
id: "codesearch",
title: "settings.permissions.tool.codesearch.title",
description: "settings.permissions.tool.codesearch.description",
},
{
id: "external_directory",
title: "settings.permissions.tool.external_directory.title",
description: "settings.permissions.tool.external_directory.description",
},
{
id: "doom_loop",
title: "settings.permissions.tool.doom_loop.title",
description: "settings.permissions.tool.doom_loop.description",
},
] as const
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
function toMap(value: unknown): PermissionMap {
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
const action = getAction(value)
if (action) return { "*": action }
return {}
}
function getAction(value: unknown): PermissionAction | undefined {
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
return
}
function getRuleDefault(value: unknown): PermissionAction | undefined {
const action = getAction(value)
if (action) return action
if (!value || typeof value !== "object" || Array.isArray(value)) return
return getAction((value as Record<string, unknown>)["*"])
}
export const SettingsPermissions: Component = () => {
const globalSync = useGlobalSync()
const language = useLanguage()
const actions = createMemo(
(): Array<{ value: PermissionAction; label: string }> =>
ACTIONS.map((action) => ({
value: action.value,
label: language.t(action.label),
})),
)
const permission = createMemo(() => {
return toMap(globalSync.data.config.permission)
})
const actionFor = (id: string): PermissionAction => {
const value = permission()[id]
const direct = getRuleDefault(value)
if (direct) return direct
const wildcard = getRuleDefault(permission()["*"])
if (wildcard) return wildcard
return "allow"
}
const setPermission = async (id: string, action: PermissionAction) => {
const before = globalSync.data.config.permission
const map = toMap(before)
const existing = map[id]
const nextValue =
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
const rollback = (err: unknown) => {
globalSync.set("config", "permission", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
}
globalSync.set("config", "permission", { ...map, [id]: nextValue })
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
</div>
</div>
<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
<div class="flex flex-col gap-2">
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
<div class="border border-border-weak-base rounded-lg overflow-hidden">
<For each={ITEMS}>
{(item) => (
<SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
<Select
options={actions()}
current={actions().find((o) => o.value === actionFor(item.id))}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && setPermission(item.id, option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
)}
</For>
</div>
</div>
</div>
</div>
)
}
interface SettingsRowProps {
title: string
description: string
children: JSX.Element
}
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex-shrink-0">{props.children}</div>
</div>
)
}

View File

@@ -11,7 +11,6 @@ import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -137,7 +136,7 @@ export const SettingsProviders: Component = () => {
<div class="flex flex-col gap-8 max-w-[720px]">
<div class="flex flex-col gap-1" data-component="connected-providers-section">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
<SettingsList>
<div class="bg-surface-raised-base px-4 rounded-lg">
<Show
when={connected().length > 0}
fallback={
@@ -170,12 +169,12 @@ export const SettingsProviders: Component = () => {
)}
</For>
</Show>
</SettingsList>
</div>
</div>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
<SettingsList>
<div class="bg-surface-raised-base px-4 rounded-lg">
<For each={popular()}>
{(item) => (
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
@@ -233,7 +232,7 @@ export const SettingsProviders: Component = () => {
{language.t("common.connect")}
</Button>
</div>
</SettingsList>
</div>
<Button
variant="ghost"

View File

@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@@ -53,8 +53,7 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const checkServerHealth = useCheckServerHealth()
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
@@ -65,7 +64,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}),
)
if (dead) return
@@ -86,17 +85,15 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [state, setState] = createStore({
url: undefined as string | undefined,
tick: 0,
})
const [url, setUrl] = createSignal<string | undefined>()
const [tick, setTick] = createSignal(0)
createEffect(() => {
state.tick
tick()
let dead = false
const result = get?.()
if (!result) {
setState("url", undefined)
setUrl(undefined)
onCleanup(() => {
dead = true
})
@@ -106,7 +103,7 @@ const useDefaultServerKey = (
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setState("url", next ? normalizeServerUrl(next) : undefined)
setUrl(next ? normalizeServerUrl(next) : undefined)
})
onCleanup(() => {
dead = true
@@ -114,7 +111,7 @@ const useDefaultServerKey = (
return
}
setState("url", normalizeServerUrl(result))
setUrl(normalizeServerUrl(result))
onCleanup(() => {
dead = true
})
@@ -122,11 +119,11 @@ const useDefaultServerKey = (
return {
key: () => {
const u = state.url
const u = url()
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setState("tick", (value) => value + 1),
refresh: () => setTick((value) => value + 1),
}
}
@@ -171,7 +168,7 @@ export function StatusPopover() {
const language = useLanguage()
const navigate = useNavigate()
const [shown, setShown] = createSignal(false)
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.current
const list = server.list
@@ -179,10 +176,10 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers)
const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -202,23 +199,18 @@ export function StatusPopover() {
return (
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs={Button}
triggerProps={{
variant: "ghost",
class: "titlebar-icon w-8 h-6 p-0 box-border",
class: "titlebar-icon w-6 h-6 p-0 box-border",
"aria-label": language.t("status.popover.trigger"),
style: { scale: 1 },
}}
trigger={
<div class="relative size-4">
<div class="badge-mask-tight size-4 flex items-center justify-center">
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div class="flex size-4 items-center justify-center">
<div
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,

View File

@@ -10,7 +10,6 @@ import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { terminalAttr, terminalProbe } from "@/testing/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
@@ -65,16 +64,6 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const errorStatus = (err: unknown) => {
if (!err || typeof err !== "object") return
if (!("data" in err)) return
const data = err.data
if (!data || typeof data !== "object") return
if (!("statusCode" in data)) return
const status = data.statusCode
return typeof status === "number" ? status : undefined
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -171,7 +160,6 @@ export const Terminal = (props: TerminalProps) => {
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
const probe = terminalProbe(id)
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
@@ -199,11 +187,7 @@ export const Terminal = (props: TerminalProps) => {
const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
let seek = start !== undefined ? start : restore ? -1 : 0
let output: ReturnType<typeof terminalWriter> | undefined
let drop: VoidFunction | undefined
let reconn: ReturnType<typeof setTimeout> | undefined
let tries = 0
const cleanup = () => {
if (!cleanups.length) return
@@ -342,9 +326,6 @@ export const Terminal = (props: TerminalProps) => {
}
onMount(() => {
probe.init()
cleanups.push(() => probe.drop())
const run = async () => {
const loaded = await loadGhostty()
if (disposed) return
@@ -372,13 +353,7 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
output = terminalWriter((data, done) =>
t.write(data, () => {
probe.render(data)
probe.settle()
done?.()
}),
)
output = terminalWriter((data, done) => t.write(data, done))
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
@@ -466,136 +441,89 @@ export const Terminal = (props: TerminalProps) => {
startResize()
}
const once = { value: false }
const decoder = new TextDecoder()
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
const fail = (err: unknown) => {
const once = { value: false }
let closing = false
const url = new URL(sdk.url + `/pty/${id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? ""
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
const handleOpen = () => {
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
if (disposed) return
if (closing) return
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
const handleError = (error: Event) => {
if (disposed) return
if (closing) return
if (once.value) return
once.value = true
local.onConnectError?.(err)
console.error("WebSocket error:", error)
local.onConnectError?.(error)
}
socket.addEventListener("error", handleError)
const gone = () =>
sdk.client.pty
.get({ ptyID: id })
.then(() => false)
.catch((err) => {
if (errorStatus(err) === 404) return true
debugTerminal("failed to inspect terminal session", err)
return false
})
const retry = (err: unknown) => {
const handleClose = (event: CloseEvent) => {
if (disposed) return
if (reconn !== undefined) return
const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000)
reconn = setTimeout(async () => {
reconn = undefined
if (disposed) return
if (await gone()) {
if (disposed) return
fail(err)
return
}
if (disposed) return
tries += 1
open()
}, ms)
if (closing) return
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
// For other codes (network issues, server restart), trigger error handler
if (event.code !== 1000) {
if (once.value) return
once.value = true
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
}
}
socket.addEventListener("close", handleClose)
const open = () => {
if (disposed) return
drop?.()
const url = new URL(sdk.url + `/pty/${id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(seek))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "opencode"
url.password = server.current?.http.password ?? ""
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
const handleOpen = () => {
if (disposed) return
tries = 0
probe.connect()
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
const handleMessage = (event: MessageEvent) => {
if (disposed) return
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
seek = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
cursor += data.length
seek = cursor
}
const handleError = (error: Event) => {
if (disposed) return
debugTerminal("websocket error", error)
}
const stop = () => {
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (ws === socket) ws = undefined
if (drop === stop) drop = undefined
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
}
const handleClose = (event: CloseEvent) => {
if (ws === socket) ws = undefined
if (drop === stop) drop = undefined
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (disposed) return
if (event.code === 1000) return
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
}
drop = stop
socket.addEventListener("open", handleOpen)
socket.addEventListener("message", handleMessage)
socket.addEventListener("error", handleError)
socket.addEventListener("close", handleClose)
}
probe.control({
disconnect: () => {
if (!ws) return
ws.close(4_000, "e2e")
},
cleanups.push(() => {
closing = true
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
})
open()
}
void run().catch((err) => {
@@ -613,8 +541,6 @@ export const Terminal = (props: TerminalProps) => {
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
if (reconn !== undefined) clearTimeout(reconn)
drop?.()
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
const finalize = () => {
@@ -634,7 +560,6 @@ export const Terminal = (props: TerminalProps) => {
<div
ref={container}
data-component="terminal"
{...{ [terminalAttr]: id }}
data-prevent-autofocus
tabIndex={-1}
style={{ "background-color": terminalColors().background }}

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -58,12 +58,6 @@ export function Titlebar() {
})
const path = () => `${location.pathname}${location.search}${location.hash}`
const creating = createMemo(() => {
if (!params.dir) return false
if (params.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/")
return parts.at(-1) === "session"
})
createEffect(() => {
const current = path()
@@ -212,7 +206,19 @@ export function Titlebar() {
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
class="group-hover/sidebar-toggle:hidden"
/>
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
<Icon
size="small"
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
@@ -225,14 +231,13 @@ export function Titlebar() {
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
icon="new-session"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</Show>
@@ -277,7 +282,7 @@ export function Titlebar() {
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="w-36 shrink-0" />}
<div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>

View File

@@ -2,7 +2,6 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "sol
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { dict as en } from "@/i18n/en"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { Persist, persisted } from "@/utils/persist"
@@ -14,27 +13,6 @@ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
type KeyLabel =
| "common.key.ctrl"
| "common.key.alt"
| "common.key.shift"
| "common.key.meta"
| "common.key.space"
| "common.key.backspace"
| "common.key.enter"
| "common.key.tab"
| "common.key.delete"
| "common.key.home"
| "common.key.end"
| "common.key.pageUp"
| "common.key.pageDown"
| "common.key.insert"
| "common.key.esc"
function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) {
return t ? t(key) : en[key]
}
function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id
return id.slice(SUGGESTED_PREFIX.length)
@@ -167,7 +145,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
return false
}
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
export function formatKeybind(config: string): string {
if (!config || config === "none") return ""
const keybinds = parseKeybind(config)
@@ -176,10 +154,10 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
const kb = keybinds[0]
const parts: string[] = []
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t))
if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const keys: Record<string, string> = {
@@ -189,29 +167,10 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
arrowright: "→",
comma: ",",
plus: "+",
}
const named: Record<string, KeyLabel> = {
backspace: "common.key.backspace",
delete: "common.key.delete",
end: "common.key.end",
enter: "common.key.enter",
esc: "common.key.esc",
escape: "common.key.esc",
home: "common.key.home",
insert: "common.key.insert",
pagedown: "common.key.pageDown",
pageup: "common.key.pageUp",
space: "common.key.space",
tab: "common.key.tab",
space: "Space",
}
const key = kb.key.toLowerCase()
const displayKey =
keys[key] ??
(named[key]
? keyText(named[key], t)
: key.length === 1
? key.toUpperCase()
: key.charAt(0).toUpperCase() + key.slice(1))
const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
parts.push(displayKey)
}
@@ -405,17 +364,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
},
keybind(id: string) {
if (id === PALETTE_ID) {
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t)
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
}
const base = actionId(id)
const option = options().find((x) => actionId(x.id) === base)
if (option?.keybind) return formatKeybind(option.keybind, language.t)
if (option?.keybind) return formatKeybind(option.keybind)
const meta = catalog[base]
const config = bind(base, meta?.keybind)
if (!config) return ""
return formatKeybind(config, language.t)
return formatKeybind(config)
},
show: showPalette,
keybinds(enabled: boolean) {

View File

@@ -43,10 +43,10 @@ export {
touchFileContent,
}
function errorMessage(error: unknown, fallback: string) {
function errorMessage(error: unknown) {
if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error) return error
return fallback
return "Unknown error"
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
@@ -184,7 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
.catch((e) => {
if (scope() !== directory) return
setLoadError(file, errorMessage(e, language.t("error.chain.unknown")))
setLoadError(file, errorMessage(e))
})
.finally(() => {
inflight.delete(key)

View File

@@ -4,7 +4,6 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js"
import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { useServer } from "./server"
@@ -15,7 +14,6 @@ const abortError = z.object({
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: () => {
const language = useLanguage()
const server = useServer()
const platform = usePlatform()
const abort = new AbortController()
@@ -32,7 +30,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
})()
const currentServer = server.current
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
if (!currentServer) throw new Error("No server available")
const eventSdk = createSdkForServer({
signal: abort.signal,
@@ -220,7 +218,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
if (!s) throw new Error("Server not available")
return createSdkForServer({
server: s.http,
fetch: platform.fetch,

View File

@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import {
canDisposeDirectory,
estimateRootSessionTotal,
loadRootSessionsWithFallback,
pickDirectoriesToEvict,
} from "./global-sync"
describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => {

View File

@@ -29,7 +29,6 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
@@ -162,9 +161,7 @@ function createGlobalSync() {
queue.clear(directory)
sessionMeta.delete(directory)
sdkCache.delete(directory)
clearSessionPrefetchDirectory(directory)
},
translate: language.t,
})
const sdkFor = (directory: string) => {
@@ -405,3 +402,6 @@ export function useGlobalSync() {
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"

View File

@@ -139,7 +139,7 @@ export async function bootstrapDirectory(input: {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
title: `Failed to reload ${project}`,
description: formatServerError(err, input.translate),
})
input.setStore("status", "partial")

View File

@@ -21,7 +21,6 @@ describe("createChildStoreManager", () => {
isLoadingSessions: () => false,
onBootstrap() {},
onDispose() {},
translate: (key) => key,
})
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {

View File

@@ -21,7 +21,6 @@ export function createChildStoreManager(input: {
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const vcsCache = new Map<string, VcsCache>()
@@ -130,7 +129,7 @@ export function createChildStoreManager(input: {
createStore({ value: undefined as VcsInfo | undefined }),
),
)
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
if (!vcs) throw new Error("Failed to create persisted cache")
const vcsStore = vcs[0]
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
@@ -140,7 +139,7 @@ export function createChildStoreManager(input: {
createStore({ value: undefined as ProjectMeta | undefined }),
),
)
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
if (!meta) throw new Error("Failed to create persisted project metadata")
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
@@ -149,7 +148,7 @@ export function createChildStoreManager(input: {
createStore({ value: undefined as string | undefined }),
),
)
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
if (!icon) throw new Error("Failed to create persisted project icon")
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
const init = () =>
@@ -212,7 +211,7 @@ export function createChildStoreManager(input: {
}
mark(directory)
const childStore = children[directory]
if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
if (!childStore) throw new Error("Failed to create store")
return childStore
}

View File

@@ -1,96 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
clearSessionPrefetch,
clearSessionPrefetchDirectory,
getSessionPrefetch,
runSessionPrefetch,
setSessionPrefetch,
shouldSkipSessionPrefetch,
} from "./session-prefetch"
describe("session prefetch", () => {
test("stores and clears message metadata by directory", () => {
clearSessionPrefetch("/tmp/a", ["ses_1"])
clearSessionPrefetch("/tmp/b", ["ses_1"])
setSessionPrefetch({
directory: "/tmp/a",
sessionID: "ses_1",
limit: 200,
cursor: "abc",
complete: false,
at: 123,
})
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
clearSessionPrefetch("/tmp/a", ["ses_1"])
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
})
test("dedupes inflight work", async () => {
clearSessionPrefetch("/tmp/c", ["ses_2"])
let calls = 0
const run = () =>
runSessionPrefetch({
directory: "/tmp/c",
sessionID: "ses_2",
task: async () => {
calls += 1
return { limit: 100, cursor: "next", complete: true, at: 456 }
},
})
const [a, b] = await Promise.all([run(), run()])
expect(calls).toBe(1)
expect(a).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
expect(b).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
})
test("clears a whole directory", () => {
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
clearSessionPrefetchDirectory("/tmp/d")
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
})
test("refreshes stale first-page prefetched history", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 200, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(false)
})
test("keeps deeper or complete history cached", () => {
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 400, cursor: "x", complete: false, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
expect(
shouldSkipSessionPrefetch({
message: true,
info: { limit: 120, complete: true, at: 1 },
chunk: 200,
now: 1 + 15_001,
}),
).toBe(true)
})
})

View File

@@ -1,100 +0,0 @@
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
export const SESSION_PREFETCH_TTL = 15_000
type Meta = {
limit: number
cursor?: string
complete: boolean
at: number
}
export function shouldSkipSessionPrefetch(input: { message: boolean; info?: Meta; chunk: number; now?: number }) {
if (input.message) {
if (!input.info) return true
if (input.info.complete) return true
if (input.info.limit > input.chunk) return true
} else {
if (!input.info) return false
}
return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
}
const cache = new Map<string, Meta>()
const inflight = new Map<string, Promise<Meta | undefined>>()
const rev = new Map<string, number>()
const version = (id: string) => rev.get(id) ?? 0
export function getSessionPrefetch(directory: string, sessionID: string) {
return cache.get(key(directory, sessionID))
}
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
return inflight.get(key(directory, sessionID))
}
export function clearSessionPrefetchInflight() {
inflight.clear()
}
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
return version(key(directory, sessionID)) === value
}
export function runSessionPrefetch(input: {
directory: string
sessionID: string
task: (value: number) => Promise<Meta | undefined>
}) {
const id = key(input.directory, input.sessionID)
const pending = inflight.get(id)
if (pending) return pending
const value = version(id)
const promise = input.task(value).finally(() => {
if (inflight.get(id) === promise) inflight.delete(id)
})
inflight.set(id, promise)
return promise
}
export function setSessionPrefetch(input: {
directory: string
sessionID: string
limit: number
cursor?: string
complete: boolean
at?: number
}) {
cache.set(key(input.directory, input.sessionID), {
limit: input.limit,
cursor: input.cursor,
complete: input.complete,
at: input.at ?? Date.now(),
})
}
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
for (const sessionID of sessionIDs) {
if (!sessionID) continue
const id = key(directory, sessionID)
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
export function clearSessionPrefetchDirectory(directory: string) {
const prefix = `${directory}\n`
const keys = new Set([...cache.keys(), ...inflight.keys()])
for (const id of keys) {
if (!id.startsWith(prefix)) continue
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}

View File

@@ -1,4 +1,4 @@
import { createEffect, onCleanup } from "solid-js"
import { createEffect, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -146,10 +146,8 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const settings = useSettings()
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
const [range, setRange] = createStore({
from: undefined as string | undefined,
to: undefined as string | undefined,
})
const [from, setFrom] = createSignal<string | undefined>(undefined)
const [to, setTo] = createSignal<string | undefined>(undefined)
const state = { started: false }
let timer: ReturnType<typeof setTimeout> | undefined
@@ -216,14 +214,15 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
if (previous === platform.version) return
setRange({ from: previous, to: platform.version })
setFrom(previous)
setTo(platform.version)
start(previous)
})
return {
ready,
from: () => range.from,
to: () => range.to,
from,
to,
get last() {
return store.version
},

View File

@@ -793,67 +793,20 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
review: {
open: createMemo(() => s().reviewOpen ?? []),
open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) {
const session = key()
const next = Array.from(new Set(open))
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, {
scroll: {},
reviewOpen: next,
reviewOpen: open,
})
return
}
if (same(current.reviewOpen, next)) return
setStore("sessionView", session, "reviewOpen", next)
},
openPath(path: string) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, {
scroll: {},
reviewOpen: [path],
})
return
}
if (!current.reviewOpen) {
setStore("sessionView", session, "reviewOpen", [path])
return
}
if (current.reviewOpen.includes(path)) return
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
},
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
if (index === -1) return
setStore(
"sessionView",
session,
"reviewOpen",
produce((draft) => {
if (!draft) return
draft.splice(index, 1)
}),
)
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
}
this.closePath(path)
if (same(current.reviewOpen, open)) return
setStore("sessionView", session, "reviewOpen", open)
},
},
}

View File

@@ -0,0 +1,66 @@
type NotificationIndexItem = {
directory?: string
session?: string
viewed: boolean
type: string
}
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
const sessionAll = new Map<string, T[]>()
const sessionUnseen = new Map<string, T[]>()
const sessionUnseenCount = new Map<string, number>()
const sessionUnseenHasError = new Map<string, boolean>()
const projectAll = new Map<string, T[]>()
const projectUnseen = new Map<string, T[]>()
const projectUnseenCount = new Map<string, number>()
const projectUnseenHasError = new Map<string, boolean>()
for (const notification of list) {
const session = notification.session
if (session) {
const all = sessionAll.get(session)
if (all) all.push(notification)
else sessionAll.set(session, [notification])
if (!notification.viewed) {
const unseen = sessionUnseen.get(session)
if (unseen) unseen.push(notification)
else sessionUnseen.set(session, [notification])
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
if (notification.type === "error") sessionUnseenHasError.set(session, true)
}
}
const directory = notification.directory
if (directory) {
const all = projectAll.get(directory)
if (all) all.push(notification)
else projectAll.set(directory, [notification])
if (!notification.viewed) {
const unseen = projectUnseen.get(directory)
if (unseen) unseen.push(notification)
else projectUnseen.set(directory, [notification])
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
if (notification.type === "error") projectUnseenHasError.set(directory, true)
}
}
}
return {
session: {
all: sessionAll,
unseen: sessionUnseen,
unseenCount: sessionUnseenCount,
unseenHasError: sessionUnseenHasError,
},
project: {
all: projectAll,
unseen: projectUnseen,
unseenCount: projectUnseenCount,
unseenHasError: projectUnseenHasError,
},
}
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import { buildNotificationIndex } from "./notification-index"
type Notification = {
type: "turn-complete" | "error"
session: string
directory: string
viewed: boolean
time: number
}
const turn = (session: string, directory: string, viewed = false): Notification => ({
type: "turn-complete",
session,
directory,
viewed,
time: 1,
})
const error = (session: string, directory: string, viewed = false): Notification => ({
type: "error",
session,
directory,
viewed,
time: 1,
})
describe("buildNotificationIndex", () => {
test("builds unseen counts and unseen error flags", () => {
const list = [
turn("s1", "d1", false),
error("s1", "d1", false),
turn("s1", "d1", true),
turn("s2", "d1", false),
error("s3", "d2", true),
]
const index = buildNotificationIndex(list)
expect(index.session.all.get("s1")?.length).toBe(3)
expect(index.session.unseen.get("s1")?.length).toBe(2)
expect(index.session.unseenCount.get("s1")).toBe(2)
expect(index.session.unseenHasError.get("s1")).toBe(true)
expect(index.session.unseenCount.get("s2")).toBe(1)
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
expect(index.project.unseenCount.get("d1")).toBe(3)
expect(index.project.unseenHasError.get("d1")).toBe(true)
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
})
test("updates selectors after viewed transitions", () => {
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
const before = buildNotificationIndex(list)
const after = buildNotificationIndex(next)
expect(before.session.unseenCount.get("s1")).toBe(2)
expect(before.session.unseenHasError.get("s1")).toBe(true)
expect(before.project.unseenCount.get("d1")).toBe(3)
expect(before.project.unseenHasError.get("d1")).toBe(true)
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
expect(after.project.unseenCount.get("d1")).toBe(1)
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
})
})

View File

@@ -1,7 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -59,10 +58,10 @@ export type Platform = {
fetch?: typeof fetch
/** Get the configured default server URL (platform-specific) */
getDefaultServer?(): Promise<ServerConnection.Key | null>
getDefaultServerUrl?(): Promise<string | null>
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>

View File

@@ -1,8 +1,9 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -95,7 +96,7 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const checkServerHealth = useCheckServerHealth()
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
@@ -196,7 +197,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
const fetcher = platform.fetch ?? globalThis.fetch
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
createEffect(() => {
const current_ = current()

View File

@@ -22,7 +22,6 @@ export interface Settings {
general: {
autoSave: boolean
releaseNotes: boolean
followup: "queue" | "steer"
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
@@ -46,7 +45,6 @@ const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
followup: "steer",
showReasoningSummaries: false,
shellToolPartsExpanded: true,
editToolPartsExpanded: false,
@@ -128,10 +126,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries,

View File

@@ -1,8 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }>
import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
const userMessage = (id: string, sessionID: string): Message => ({
id,
@@ -13,7 +11,7 @@ const userMessage = (id: string, sessionID: string): Message => ({
model: { providerID: "openai", modelID: "gpt" },
})
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
const textPart = (id: string, sessionID: string, messageID: string): Part => ({
id,
sessionID,
messageID,
@@ -55,69 +53,4 @@ describe("sync optimistic reducers", () => {
expect(draft.part.msg_1).toBeUndefined()
expect(draft.part.msg_2).toHaveLength(1)
})
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_1", sessionID)],
part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
complete: true,
},
[{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
)
expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true)
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
expect(page.confirmed).toEqual([])
})
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [
{
id: "msg_2",
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
},
],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.confirmed).toEqual(["msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
{ id: "prt_1", type: "text", text: "server" },
{ id: "prt_2", type: "text", text: "prt_2" },
])
})
})

View File

@@ -3,12 +3,6 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -32,12 +26,6 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@@ -54,67 +42,6 @@ type OptimisticRemoveInput = {
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
@@ -182,12 +109,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
@@ -199,33 +124,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
@@ -248,15 +146,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
@@ -266,7 +160,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
@@ -287,24 +180,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
evict(directory, setStore, stale)
}
const fetchMessages = async (input: {
client: typeof sdk.client
sessionID: string
limit: number
before?: string
}) => {
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
complete: session.length < input.limit,
}
}
@@ -316,50 +202,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
.then((next) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
for (const p of next.part) {
input.setStore("part", p.id, p.part)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("limit", key, input.limit)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
if (!tracked(input.directory, input.sessionID)) return
setMeta("loading", key, false)
})
}
@@ -386,15 +248,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
clearOptimistic(directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
@@ -416,91 +274,60 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
variant: input.variant,
}
const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
async sync(sessionID: string) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const limit = meta.limit[key] ?? messagePageSize
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const sessionReq = hasSession
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const limit = meta.limit[key] ?? messagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
const messagesReq = loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
},
async diff(sessionID: string, opts?: { force?: boolean }) {
async diff(sessionID: string) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
if (store.session_diff[sessionID] !== undefined) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
@@ -510,7 +337,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
async todo(sessionID: string) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
@@ -521,7 +348,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
return
}
if (cached !== undefined) {
@@ -545,7 +372,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
return true
},
loading(sessionID: string) {
const key = keyFor(sdk.directory, sessionID)
@@ -560,17 +387,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const step = count ?? messagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
const currentLimit = meta.limit[key] ?? messagePageSize
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
limit: currentLimit + step,
})
},
},

View File

@@ -1,51 +0,0 @@
import { dict as ar } from "@/i18n/ar"
import { dict as br } from "@/i18n/br"
import { dict as bs } from "@/i18n/bs"
import { dict as da } from "@/i18n/da"
import { dict as de } from "@/i18n/de"
import { dict as en } from "@/i18n/en"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as ja } from "@/i18n/ja"
import { dict as ko } from "@/i18n/ko"
import { dict as no } from "@/i18n/no"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as th } from "@/i18n/th"
import { dict as tr } from "@/i18n/tr"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
const numbered = Array.from(
new Set([
en["terminal.title.numbered"],
ar["terminal.title.numbered"],
br["terminal.title.numbered"],
bs["terminal.title.numbered"],
da["terminal.title.numbered"],
de["terminal.title.numbered"],
es["terminal.title.numbered"],
fr["terminal.title.numbered"],
ja["terminal.title.numbered"],
ko["terminal.title.numbered"],
no["terminal.title.numbered"],
pl["terminal.title.numbered"],
ru["terminal.title.numbered"],
th["terminal.title.numbered"],
tr["terminal.title.numbered"],
zh["terminal.title.numbered"],
zht["terminal.title.numbered"],
]),
)
export function defaultTitle(number: number) {
return en["terminal.title.numbered"].replace("{{number}}", String(number))
}
export function isDefaultTitle(title: string, number: number) {
return numbered.some((text) => title === text.replace("{{number}}", String(number)))
}
export function titleNumber(title: string, max: number) {
return Array.from({ length: max }, (_, idx) => idx + 1).find((number) => isDefaultTitle(title, number))
}

View File

@@ -4,7 +4,6 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
export type LocalPTY = {
@@ -34,7 +33,11 @@ function num(value: unknown) {
}
function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
const match = title.match(/^Terminal (\d+)$/)
if (!match) return
const value = Number(match[1])
if (!Number.isFinite(value) || value <= 0) return
return value
}
function pty(value: unknown): LocalPTY | undefined {
@@ -199,13 +202,13 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
const nextNumber = pickNextTerminalNumber()
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.create({ title: `Terminal ${nextNumber}` })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) return
const newTerminal = {
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
title: pty.data?.title ?? "Terminal",
titleNumber: nextNumber,
}
setStore("all", store.all.length, newTerminal)

View File

@@ -1,5 +1,6 @@
// @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
@@ -97,19 +98,6 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}
const getDefaultUrl = () => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
return getCurrentUrl()
}
const platform: Platform = {
platform: "web",
version: pkg.version,
@@ -118,24 +106,26 @@ const platform: Platform = {
forward,
restart,
notify,
getDefaultServer: async () => {
const stored = readDefaultServerUrl()
return stored ? ServerConnection.Key.make(stored) : null
},
setDefaultServer: writeDefaultServerUrl,
getDefaultServerUrl: async () => readDefaultServerUrl(),
setDefaultServerUrl: writeDefaultServerUrl,
}
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
servers={[server]}
disableHealthCheck
/>
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
</AppBaseProviders>
</PlatformProvider>
),

View File

@@ -18,27 +18,25 @@ const popularProviderSet = new Set(popularProviders)
export function useProviders() {
const globalSync = useGlobalSync()
const params = useParams()
const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = () => {
if (dir()) {
const [projectStore] = globalSync.child(dir())
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
const providers = createMemo(() => {
if (currentDirectory()) {
const [projectStore] = globalSync.child(currentDirectory())
return projectStore.provider
}
return globalSync.data.provider
}
})
const connectedIDs = createMemo(() => new Set(providers().connected))
const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
const paid = createMemo(() =>
connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
)
const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
return {
all: () => providers().all,
default: () => providers().default,
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
connected: () => {
const connected = new Set(providers().connected)
return providers().all.filter((p) => connected.has(p.id))
},
paid: () => {
const connected = new Set(providers().connected)
return providers().all.filter(
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
)
},
all: createMemo(() => providers().all),
default: createMemo(() => providers().default),
popular,
connected,
paid,
}
}

View File

@@ -104,7 +104,6 @@ export const dict = {
"dialog.model.empty": "لا توجد نتائج للنماذج",
"dialog.model.manage": "إدارة النماذج",
"dialog.model.manage.description": "تخصيص النماذج التي تظهر في محدد النماذج.",
"dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}",
"dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode",
"dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
"dialog.provider.viewAll": "عرض المزيد من الموفرين",
@@ -244,7 +243,7 @@ export const dict = {
"prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
"prompt.dropzone.file.label": "أفلت لإشارة @ للملف",
"prompt.slash.badge.custom": "مخصص",
"prompt.slash.badge.skill": "مهارة",
@@ -257,8 +256,8 @@ export const dict = {
"prompt.attachment.remove": "إزالة المرفق",
"prompt.action.send": "إرسال",
"prompt.action.stop": "توقف",
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
"prompt.toast.pasteUnsupported.title": "لصق غير مدعوم",
"prompt.toast.pasteUnsupported.description": "يمكن لصق الصور أو ملفات PDF فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
"prompt.toast.worktreeCreateFailed.title": "فشل إنشاء شجرة العمل",
@@ -289,11 +288,6 @@ export const dict = {
"dialog.server.add.error": "تعذر الاتصال بالخادم",
"dialog.server.add.checking": "جارٍ التحقق...",
"dialog.server.add.button": "إضافة خادم",
"dialog.server.add.name": "اسم الخادم (اختياري)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "اسم المستخدم (اختياري)",
"dialog.server.add.password": "كلمة المرور (اختياري)",
"dialog.server.edit.title": "تحرير الخادم",
"dialog.server.default.title": "الخادم الافتراضي",
"dialog.server.default.description":
"الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
@@ -364,7 +358,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "لغة",
"toast.language.description": "تم التبديل إلى {{language}}",
"toast.theme.title": "تم تبديل السمة",
@@ -451,11 +444,8 @@ export const dict = {
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
"session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد",
"session.review.noChanges": "لا توجد تغييرات",
"session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات",
"session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
"session.files.selectToOpen": "اختر ملفًا لفتحه",
"session.files.all": "كل الملفات",
"session.files.empty": "لا توجد ملفات",
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
"session.messages.renderEarlier": "عرض الرسائل السابقة",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
@@ -466,17 +456,6 @@ export const dict = {
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.followupDock.summary.one": "{{count}} رسالة في الانتظار",
"session.followupDock.summary.other": "{{count}} رسائل في الانتظار",
"session.followupDock.sendNow": "إرسال الآن",
"session.followupDock.edit": "تحرير",
"session.followupDock.collapse": "طي الرسائل المنتظرة",
"session.followupDock.expand": "توسيع الرسائل المنتظرة",
"session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها",
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
"session.revertDock.restore": "استعادة الرسالة",
"session.new.title": "ابنِ أي شيء",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
@@ -559,18 +538,10 @@ export const dict = {
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
"settings.general.row.appearance.title": "المظهر",
"settings.general.row.appearance.description": "تخصيص كيفية ظهور OpenCode على جهازك",
"settings.general.row.colorScheme.title": "مخطط الألوان",
"settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن",
"settings.general.row.theme.title": "السمة",
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
"settings.general.row.font.title": "الخط",
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
"settings.general.row.followup.title": "سلوك المتابعة",
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
"settings.general.row.followup.option.queue": "قائمة انتظار",
"settings.general.row.followup.option.steer": "توجيه",
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج",
"settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني",
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
"settings.general.row.shellToolPartsExpanded.description":
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
@@ -778,77 +749,4 @@ export const dict = {
"common.time.daysAgo.short": "قبل {{count}} ي",
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
"app.server.retrying": "جاري إعادة المحاولة تلقائيًا...",
"app.server.otherServers": "خوادم أخرى",
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
"server.row.noUsername": "لا يوجد اسم مستخدم",
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
"session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...",
"session.review.noVcs.createGit.action": "إنشاء مستودع Git",
"session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
"session.question.progress": "{{current}} من {{total}} أسئلة",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "مستكشف الملفات",
"session.header.open.fileManager": "مدير الملفات",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "المحطة الطرفية",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "تشخيص أداء التطوير",
"debugBar.na": "غير متاح",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip": "آخر انتقال مكتمل للمسار يمس صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "الإطارات المتجددة في الثانية خلال آخر 5 ثوانٍ.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "الإطارات التي تزيد عن 32 مللي ثانية في آخر 5 ثوانٍ.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "الوقت المحظور وعدد المهام الطويلة في آخر 5 ثوانٍ. أقصى مهمة: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "أسوأ تأخير إدخال تمت ملاحظته في آخر 5 ثوانٍ.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "التحول التخطيطي التراكمي لعمر التطبيق الحالي.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.",
"debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "غير معروف",
"error.page.circular": "[دائري]",
"error.globalSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.globalSDK.serverNotAvailable": "الخادم غير متاح",
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
"error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
"error.childStore.storeCreateFailed": "فشل إنشاء المخزن",
"terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}",
}

View File

@@ -104,7 +104,6 @@ export const dict = {
"dialog.model.empty": "Nenhum resultado de modelo",
"dialog.model.manage": "Gerenciar modelos",
"dialog.model.manage.description": "Personalizar quais modelos aparecem no seletor de modelos.",
"dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}",
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode",
"dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
"dialog.provider.viewAll": "Ver mais provedores",
@@ -244,7 +243,7 @@ export const dict = {
"prompt.example.25": "Como funcionam as variáveis de ambiente aqui?",
"prompt.popover.emptyResults": "Nenhum resultado correspondente",
"prompt.popover.emptyCommands": "Nenhum comando correspondente",
"prompt.dropzone.label": "Arraste imagens, PDFs ou arquivos de texto aqui",
"prompt.dropzone.label": "Solte imagens ou PDFs aqui",
"prompt.dropzone.file.label": "Solte para @mencionar arquivo",
"prompt.slash.badge.custom": "personalizado",
"prompt.slash.badge.skill": "skill",
@@ -257,8 +256,8 @@ export const dict = {
"prompt.attachment.remove": "Remover anexo",
"prompt.action.send": "Enviar",
"prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
"prompt.toast.pasteUnsupported.title": "Colagem não suportada",
"prompt.toast.pasteUnsupported.description": "Somente imagens ou PDFs podem ser colados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
"prompt.toast.worktreeCreateFailed.title": "Falha ao criar worktree",
@@ -289,11 +288,6 @@ export const dict = {
"dialog.server.add.error": "Não foi possível conectar ao servidor",
"dialog.server.add.checking": "Verificando...",
"dialog.server.add.button": "Adicionar",
"dialog.server.add.name": "Nome do servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nome de usuário (opcional)",
"dialog.server.add.password": "Senha (opcional)",
"dialog.server.edit.title": "Editar servidor",
"dialog.server.default.title": "Servidor padrão",
"dialog.server.default.description":
"Conectar a este servidor na inicialização do aplicativo ao invés de iniciar um servidor local. Requer reinicialização.",
@@ -365,7 +359,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Idioma",
"toast.language.description": "Alterado para {{language}}",
"toast.theme.title": "Tema alterado",
@@ -453,13 +446,9 @@ export const dict = {
"session.review.change.other": "Alterações",
"session.review.loadingChanges": "Carregando alterações...",
"session.review.empty": "Nenhuma alteração nesta sessão ainda",
"session.review.noVcs": "Nenhum Sistema de Controle de Versão Git detectado, alterações não exibidas",
"session.review.noSnapshot":
"O rastreamento de snapshot está desabilitado na configuração, então as alterações da sessão estão indisponíveis",
"session.review.noChanges": "Sem alterações",
"session.files.selectToOpen": "Selecione um arquivo para abrir",
"session.files.all": "Todos os arquivos",
"session.files.empty": "Nenhum arquivo",
"session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
"session.messages.renderEarlier": "Renderizar mensagens anteriores",
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
@@ -470,17 +459,6 @@ export const dict = {
"session.todo.title": "Tarefas",
"session.todo.collapse": "Recolher",
"session.todo.expand": "Expandir",
"session.followupDock.summary.one": "{{count}} mensagem na fila",
"session.followupDock.summary.other": "{{count}} mensagens na fila",
"session.followupDock.sendNow": "Enviar agora",
"session.followupDock.edit": "Editar",
"session.followupDock.collapse": "Recolher mensagens na fila",
"session.followupDock.expand": "Expandir mensagens na fila",
"session.revertDock.summary.one": "{{count}} mensagem revertida",
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
"session.revertDock.collapse": "Recolher mensagens revertidas",
"session.revertDock.expand": "Expandir mensagens revertidas",
"session.revertDock.restore": "Restaurar mensagem",
"session.new.title": "Crie qualquer coisa",
"session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
@@ -566,19 +544,10 @@ export const dict = {
"settings.general.row.language.description": "Alterar o idioma de exibição do OpenCode",
"settings.general.row.appearance.title": "Aparência",
"settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo",
"settings.general.row.colorScheme.title": "Esquema de cores",
"settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
"settings.general.row.font.title": "Fonte",
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
"settings.general.row.followup.title": "Comportamento de acompanhamento",
"settings.general.row.followup.description":
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
"settings.general.row.followup.option.queue": "Fila",
"settings.general.row.followup.option.steer": "Orientar",
"settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio",
"settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo",
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
"settings.general.row.shellToolPartsExpanded.description":
"Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
@@ -788,79 +757,4 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}d atrás",
"settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente",
"settings.providers.custom.description": "Adicionar um provedor compatível com a OpenAI através do URL base.",
"app.server.unreachable": "Não foi possível conectar a {{server}}",
"app.server.retrying": "Tentando novamente automaticamente...",
"app.server.otherServers": "Outros servidores",
"dialog.server.add.usernamePlaceholder": "nome de usuário",
"dialog.server.add.passwordPlaceholder": "senha",
"server.row.noUsername": "sem nome de usuário",
"session.review.noVcs.createGit.title": "Criar um repositório Git",
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
"session.review.noVcs.createGit.action": "Criar repositório Git",
"session.todo.progress": "{{done}} de {{total}} tarefas concluídas",
"session.question.progress": "{{current}} de {{total}} perguntas",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorador de Arquivos",
"session.header.open.fileManager": "Gerenciador de Arquivos",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Diagnóstico de desempenho de desenvolvimento",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Última transição de rota concluída tocando em uma página de sessão, medida desde o início do roteador até a primeira pintura após o estabelecimento.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Quadros por segundo nos últimos 5 segundos.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Pior tempo de quadro nos últimos 5 segundos.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Quadros acima de 32ms nos últimos 5 segundos.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Tempo bloqueado e contagem de tarefas longas nos últimos 5 segundos. Tarefa máx: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Pior atraso de entrada observado nos últimos 5 segundos.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Duração aproximada da interação nos últimos 5 segundos. Isso é semelhante ao INP, não o INP oficial do Web Vitals.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Mudança cumulativa de layout para o tempo de vida atual do aplicativo.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.",
"debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Espaço",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "desconhecido",
"error.page.circular": "[Circular]",
"error.globalSDK.noServerAvailable": "Nenhum servidor disponível",
"error.globalSDK.serverNotAvailable": "Servidor indisponível",
"error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
"error.childStore.storeCreateFailed": "Falha ao criar armazenamento",
"terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}",
}

View File

@@ -113,7 +113,6 @@ export const dict = {
"dialog.model.empty": "Nema rezultata za modele",
"dialog.model.manage": "Upravljaj modelima",
"dialog.model.manage.description": "Prilagodi koji se modeli prikazuju u izborniku modela.",
"dialog.model.manage.provider.toggle": "Uključi/isključi sve {{provider}} modele",
"dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje OpenCode",
"dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera",
@@ -264,7 +263,7 @@ export const dict = {
"prompt.popover.emptyResults": "Nema rezultata",
"prompt.popover.emptyCommands": "Nema komandi",
"prompt.dropzone.label": "Ovdje prevucite slike, PDF-ove ili tekstualne datoteke",
"prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje",
"prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
"prompt.slash.badge.custom": "prilagođeno",
"prompt.slash.badge.skill": "skill",
@@ -278,8 +277,8 @@ export const dict = {
"prompt.action.send": "Pošalji",
"prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
"prompt.toast.pasteUnsupported.title": "Nepodržano lijepljenje",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu zalijepiti samo slike ili PDF-ovi.",
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
"prompt.toast.worktreeCreateFailed.title": "Neuspješno kreiranje worktree-a",
@@ -316,11 +315,6 @@ export const dict = {
"dialog.server.add.error": "Nije moguće povezati se na server",
"dialog.server.add.checking": "Provjera...",
"dialog.server.add.button": "Dodaj server",
"dialog.server.add.name": "Ime servera (opcionalno)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Korisničko ime (opcionalno)",
"dialog.server.add.password": "Lozinka (opcionalno)",
"dialog.server.edit.title": "Uredi server",
"dialog.server.default.title": "Podrazumijevani server",
"dialog.server.default.description":
"Poveži se na ovaj server pri pokretanju aplikacije umjesto pokretanja lokalnog servera. Potreban je restart.",
@@ -399,7 +393,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Jezik",
"toast.language.description": "Prebačeno na {{language}}",
@@ -505,14 +498,10 @@ export const dict = {
"session.review.change.other": "Izmjene",
"session.review.loadingChanges": "Učitavanje izmjena...",
"session.review.empty": "Još nema izmjena u ovoj sesiji",
"session.review.noVcs": "Nije detektovan Git sistem kontrole verzija, promjene se ne prikazuju",
"session.review.noSnapshot":
"Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne",
"session.review.noChanges": "Nema izmjena",
"session.files.selectToOpen": "Odaberi datoteku za otvaranje",
"session.files.all": "Sve datoteke",
"session.files.empty": "Nema datoteka",
"session.files.binaryContent": "Binarna datoteka (sadržaj se ne može prikazati)",
"session.messages.renderEarlier": "Prikaži ranije poruke",
@@ -525,17 +514,6 @@ export const dict = {
"session.todo.title": "Zadaci",
"session.todo.collapse": "Sažmi",
"session.todo.expand": "Proširi",
"session.followupDock.summary.one": "{{count}} poruka na čekanju",
"session.followupDock.summary.other": "{{count}} poruka na čekanju",
"session.followupDock.sendNow": "Pošalji sada",
"session.followupDock.edit": "Uredi",
"session.followupDock.collapse": "Sažmi poruke na čekanju",
"session.followupDock.expand": "Proširi poruke na čekanju",
"session.revertDock.summary.one": "{{count}} vraćena poruka",
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
"session.revertDock.collapse": "Sažmi vraćene poruke",
"session.revertDock.expand": "Proširi vraćene poruke",
"session.revertDock.restore": "Vrati poruku",
"session.new.title": "Napravi bilo šta",
"session.new.worktree.main": "Glavna grana",
@@ -631,18 +609,10 @@ export const dict = {
"settings.general.row.language.description": "Promijeni jezik prikaza u OpenCode-u",
"settings.general.row.appearance.title": "Izgled",
"settings.general.row.appearance.description": "Prilagodi kako OpenCode izgleda na tvom uređaju",
"settings.general.row.colorScheme.title": "Šema boja",
"settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
"settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
"settings.general.row.followup.option.queue": "Red čekanja",
"settings.general.row.followup.option.steer": "Usmjeri",
"settings.general.row.reasoningSummaries.title": "Prikaži sažetke rasuđivanja",
"settings.general.row.reasoningSummaries.description": "Prikaži sažetke rasuđivanja modela na vremenskoj traci",
"settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
"settings.general.row.shellToolPartsExpanded.description":
@@ -864,79 +834,4 @@ export const dict = {
"common.time.daysAgo.short": "prije {{count}} d",
"settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja",
"settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.",
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
"app.server.retrying": "Automatski ponovni pokušaj...",
"app.server.otherServers": "Drugi serveri",
"dialog.server.add.usernamePlaceholder": "korisničko ime",
"dialog.server.add.passwordPlaceholder": "lozinka",
"server.row.noUsername": "nema korisničkog imena",
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
"session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu",
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
"session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
"session.todo.progress": "{{done}} od {{total}} zadataka završeno",
"session.question.progress": "{{current}} od {{total}} pitanja",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",
"session.header.open.fileManager": "File Manager",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Dijagnostika performansi razvoja",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Posljednji završeni prelazak rute koji dotiče stranicu sesije, mjeren od početka rutera do prvog iscrtavanja nakon smirivanja.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Kadrovi u sekundi tokom posljednjih 5 sekundi.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Najgore vrijeme kadra u posljednjih 5 sekundi.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Kadrovi duži od 32ms u posljednjih 5 sekundi.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blokirano vrijeme i broj dugih zadataka u posljednjih 5 sekundi. Maks zadatak: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Najgore zabilježeno kašnjenje unosa u posljednjih 5 sekundi.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Približno trajanje interakcije tokom posljednjih 5 sekundi. Ovo je slično INP-u, nije službeni Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulativni pomak rasporeda za trenutni životni vijek aplikacije.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.",
"debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "nepoznato",
"error.page.circular": "[Kružno]",
"error.globalSDK.noServerAvailable": "Nema dostupnog servera",
"error.globalSDK.serverNotAvailable": "Server nije dostupan",
"error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
"error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
"error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta",
"terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}",
}

View File

@@ -113,7 +113,6 @@ export const dict = {
"dialog.model.empty": "Ingen modeller fundet",
"dialog.model.manage": "Administrer modeller",
"dialog.model.manage.description": "Tilpas hvilke modeller der vises i modelvælgeren.",
"dialog.model.manage.provider.toggle": "Skift alle {{provider}}-modeller",
"dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode",
"dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere",
@@ -262,7 +261,7 @@ export const dict = {
"prompt.popover.emptyResults": "Ingen matchende resultater",
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slip billeder, PDF'er eller tekstfiler her",
"prompt.dropzone.label": "Slip billeder eller PDF'er her",
"prompt.dropzone.file.label": "Slip for at @nævne fil",
"prompt.slash.badge.custom": "brugerdefineret",
"prompt.slash.badge.skill": "skill",
@@ -276,8 +275,8 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet indsæt",
"prompt.toast.pasteUnsupported.description": "Kun billeder eller PDF'er kan indsættes her.",
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
"prompt.toast.worktreeCreateFailed.title": "Kunne ikke oprette worktree",
@@ -314,11 +313,6 @@ export const dict = {
"dialog.server.add.error": "Kunne ikke forbinde til server",
"dialog.server.add.checking": "Tjekker...",
"dialog.server.add.button": "Tilføj server",
"dialog.server.add.name": "Servernavn (valgfrit)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Brugernavn (valgfrit)",
"dialog.server.add.password": "Adgangskode (valgfrit)",
"dialog.server.edit.title": "Rediger server",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
@@ -397,7 +391,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Sprog",
"toast.language.description": "Skiftede til {{language}}",
@@ -502,13 +495,9 @@ export const dict = {
"session.review.change.other": "Ændringer",
"session.review.loadingChanges": "Indlæser ændringer...",
"session.review.empty": "Ingen ændringer i denne session endnu",
"session.review.noVcs": "Intet Git versionsstyringssystem fundet, ændringer vises ikke",
"session.review.noSnapshot":
"Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige",
"session.review.noChanges": "Ingen ændringer",
"session.files.selectToOpen": "Vælg en fil at åbne",
"session.files.all": "Alle filer",
"session.files.empty": "Ingen filer",
"session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
"session.messages.renderEarlier": "Vis tidligere beskeder",
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
@@ -520,17 +509,6 @@ export const dict = {
"session.todo.title": "Opgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Udvid",
"session.followupDock.summary.one": "{{count}} besked i kø",
"session.followupDock.summary.other": "{{count}} beskeder i kø",
"session.followupDock.sendNow": "Send nu",
"session.followupDock.edit": "Rediger",
"session.followupDock.collapse": "Skjul beskeder i kø",
"session.followupDock.expand": "Udvid beskeder i kø",
"session.revertDock.summary.one": "{{count}} tilbagerullet besked",
"session.revertDock.summary.other": "{{count}} tilbagerullede beskeder",
"session.revertDock.collapse": "Skjul tilbagerullede beskeder",
"session.revertDock.expand": "Udvid tilbagerullede beskeder",
"session.revertDock.restore": "Gendan besked",
"session.new.title": "Byg hvad som helst",
"session.new.worktree.main": "Hovedgren",
@@ -626,18 +604,10 @@ export const dict = {
"settings.general.row.language.description": "Ændr visningssproget for OpenCode",
"settings.general.row.appearance.title": "Udseende",
"settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed",
"settings.general.row.colorScheme.title": "Farveskema",
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
"settings.general.row.font.title": "Skrifttype",
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
"settings.general.row.followup.title": "Opfølgningsadfærd",
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
"settings.general.row.followup.option.queue": "Kø",
"settings.general.row.followup.option.steer": "Styr",
"settings.general.row.reasoningSummaries.title": "Vis tænkeoversigter",
"settings.general.row.reasoningSummaries.description": "Vis model tænkeoversigter i tidslinjen",
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
@@ -858,79 +828,4 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}d siden",
"settings.providers.connected.environmentDescription": "Tilsluttet fra dine miljøvariabler",
"settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.",
"app.server.unreachable": "Kunne ikke nå {{server}}",
"app.server.retrying": "Prøver igen automatisk...",
"app.server.otherServers": "Andre servere",
"dialog.server.add.usernamePlaceholder": "brugernavn",
"dialog.server.add.passwordPlaceholder": "adgangskode",
"server.row.noUsername": "intet brugernavn",
"session.review.noVcs.createGit.title": "Opret et Git-repository",
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
"session.review.noVcs.createGit.action": "Opret Git-repository",
"session.todo.progress": "{{done}} af {{total}} opgaver fuldført",
"session.question.progress": "{{current}} af {{total}} spørgsmål",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Stifinder",
"session.header.open.fileManager": "Filhåndtering",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Udviklingsydelsesdiagnostik",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Rullende billeder pr. sekund over de sidste 5 sekunder.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Værste billedtid over de sidste 5 sekunder.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Billeder over 32ms i de sidste 5 sekunder.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Værste observerede inputforsinkelse i de sidste 5 sekunder.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Omtrentlig interaktionsvarighed over de sidste 5 sekunder. Dette er INP-lignende, ikke den officielle Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Brugt JS-heap vs heap-grænse. Kun Chromium.",
"debugBar.mem.tip": "Brugt JS-heap vs heap-grænse. {{used}} af {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Mellemrum",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "ukendt",
"error.page.circular": "[Cirkulær]",
"error.globalSDK.noServerAvailable": "Ingen server tilgængelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgængelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
"error.childStore.storeCreateFailed": "Kunne ikke oprette lager",
"terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}",
}

View File

@@ -108,7 +108,6 @@ export const dict = {
"dialog.model.empty": "Keine Modellergebnisse",
"dialog.model.manage": "Modelle verwalten",
"dialog.model.manage.description": "Anpassen, welche Modelle in der Modellauswahl erscheinen.",
"dialog.model.manage.provider.toggle": "Alle {{provider}}-Modelle umschalten",
"dialog.model.unpaid.freeModels.title": "Kostenlose Modelle von OpenCode",
"dialog.model.unpaid.addMore.title": "Weitere Modelle von beliebten Anbietern hinzufügen",
"dialog.provider.viewAll": "Mehr Anbieter anzeigen",
@@ -249,7 +248,7 @@ export const dict = {
"prompt.example.25": "Wie funktionieren Umgebungsvariablen hier?",
"prompt.popover.emptyResults": "Keine passenden Ergebnisse",
"prompt.popover.emptyCommands": "Keine passenden Befehle",
"prompt.dropzone.label": "Bilder, PDFs oder Textdateien hier ablegen",
"prompt.dropzone.label": "Bilder oder PDFs hier ablegen",
"prompt.dropzone.file.label": "Ablegen zum @Erwähnen der Datei",
"prompt.slash.badge.custom": "benutzerdefiniert",
"prompt.slash.badge.skill": "Skill",
@@ -262,8 +261,8 @@ export const dict = {
"prompt.attachment.remove": "Anhang entfernen",
"prompt.action.send": "Senden",
"prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
"prompt.toast.pasteUnsupported.title": "Nicht unterstütztes Einfügen",
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder oder PDFs eingefügt werden.",
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
"prompt.toast.modelAgentRequired.description":
"Wählen Sie einen Agenten und ein Modell, bevor Sie eine Eingabe senden.",
@@ -295,11 +294,6 @@ export const dict = {
"dialog.server.add.error": "Verbindung zum Server fehlgeschlagen",
"dialog.server.add.checking": "Prüfen...",
"dialog.server.add.button": "Server hinzufügen",
"dialog.server.add.name": "Servername (optional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Benutzername (optional)",
"dialog.server.add.password": "Passwort (optional)",
"dialog.server.edit.title": "Server bearbeiten",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Beim App-Start mit diesem Server verbinden, anstatt einen lokalen Server zu starten. Erfordert Neustart.",
@@ -372,7 +366,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Sprache",
"toast.language.description": "Zu {{language}} gewechselt",
"toast.theme.title": "Thema gewechselt",
@@ -461,13 +454,9 @@ export const dict = {
"session.review.change.other": "Änderungen",
"session.review.loadingChanges": "Lade Änderungen...",
"session.review.empty": "Noch keine Änderungen in dieser Sitzung",
"session.review.noVcs": "Kein Git-Versionskontrollsystem erkannt, Änderungen werden nicht angezeigt",
"session.review.noSnapshot":
"Snapshot-Tracking ist in der Konfiguration deaktiviert, daher sind Sitzungsänderungen nicht verfügbar",
"session.review.noChanges": "Keine Änderungen",
"session.files.selectToOpen": "Datei zum Öffnen auswählen",
"session.files.all": "Alle Dateien",
"session.files.empty": "Keine Dateien",
"session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
"session.messages.renderEarlier": "Frühere Nachrichten rendern",
"session.messages.loadingEarlier": "Lade frühere Nachrichten...",
@@ -478,17 +467,6 @@ export const dict = {
"session.todo.title": "Aufgaben",
"session.todo.collapse": "Einklappen",
"session.todo.expand": "Ausklappen",
"session.followupDock.summary.one": "{{count}} Nachricht in der Warteschlange",
"session.followupDock.summary.other": "{{count}} Nachrichten in der Warteschlange",
"session.followupDock.sendNow": "Jetzt senden",
"session.followupDock.edit": "Bearbeiten",
"session.followupDock.collapse": "Warteschlange einklappen",
"session.followupDock.expand": "Warteschlange ausklappen",
"session.revertDock.summary.one": "{{count}} zurückgesetzte Nachricht",
"session.revertDock.summary.other": "{{count}} zurückgesetzte Nachrichten",
"session.revertDock.collapse": "Zurückgesetzte Nachrichten einklappen",
"session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
"session.revertDock.restore": "Nachricht wiederherstellen",
"session.new.title": "Baue, was du willst",
"session.new.worktree.main": "Haupt-Branch",
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
@@ -575,21 +553,10 @@ export const dict = {
"settings.general.row.language.description": "Die Anzeigesprache für OpenCode ändern",
"settings.general.row.appearance.title": "Erscheinungsbild",
"settings.general.row.appearance.description": "Anpassen, wie OpenCode auf Ihrem Gerät aussieht",
"settings.general.row.colorScheme.title": "Farbschema",
"settings.general.row.colorScheme.description":
"Wählen Sie, ob OpenCode dem System-, hellen oder dunklen Thema folgt",
"settings.general.row.theme.title": "Thema",
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
"settings.general.row.font.title": "Schriftart",
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
"settings.general.row.followup.description":
"Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
"settings.general.row.followup.option.queue": "Warteschlange",
"settings.general.row.followup.option.steer": "Steuern",
"settings.general.row.reasoningSummaries.title": "Reasoning-Zusammenfassungen anzeigen",
"settings.general.row.reasoningSummaries.description":
"Zusammenfassungen des Modell-Reasonings in der Timeline anzeigen",
"settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
"settings.general.row.shellToolPartsExpanded.description":
"Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
@@ -799,80 +766,4 @@ export const dict = {
"common.time.daysAgo.short": "vor {{count}} Tg",
"settings.providers.connected.environmentDescription": "Verbunden aus Ihren Umgebungsvariablen",
"settings.providers.custom.description": "Fügen Sie einen OpenAI-kompatiblen Anbieter per Basis-URL hinzu.",
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
"app.server.retrying": "Automatische erneute Verbindung...",
"app.server.otherServers": "Andere Server",
"dialog.server.add.usernamePlaceholder": "Benutzername",
"dialog.server.add.passwordPlaceholder": "Passwort",
"server.row.noUsername": "Kein Benutzername",
"session.review.noVcs.createGit.title": "Git-Repository erstellen",
"session.review.noVcs.createGit.description":
"Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",
"session.review.noVcs.createGit.actionLoading": "Git-Repository wird erstellt...",
"session.review.noVcs.createGit.action": "Git-Repository erstellen",
"session.todo.progress": "{{done}} von {{total}} Aufgaben erledigt",
"session.question.progress": "{{current}} von {{total}} Fragen",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Datei-Explorer",
"session.header.open.fileManager": "Dateimanager",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Entwicklungs-Leistungsdiagnose",
"debugBar.na": "n.v.",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Letzter abgeschlossener Routenübergang, der eine Sitzungsseite berührt, gemessen vom Start des Routers bis zum ersten Rendern nach dem Einschwingen.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Gleitende Bilder pro Sekunde in den letzten 5 Sekunden.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Schlechteste Frame-Zeit in den letzten 5 Sekunden.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Frames über 32ms in den letzten 5 Sekunden.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blockierte Zeit und Anzahl langer Aufgaben in den letzten 5 Sekunden. Max Aufgabe: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Schlechteste beobachtete Eingabeverzögerung in den letzten 5 Sekunden.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Ungefähre Interaktionsdauer in den letzten 5 Sekunden. Dies ist INP-ähnlich, nicht das offizielle Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulative Layoutverschiebung für die aktuelle App-Lebensdauer.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Verwendeter JS-Heap vs Heap-Limit. Nur Chromium.",
"debugBar.mem.tip": "Verwendeter JS-Heap vs Heap-Limit. {{used}} von {{limit}}.",
"common.key.ctrl": "Strg",
"common.key.alt": "Alt",
"common.key.shift": "Umschalt",
"common.key.meta": "Meta",
"common.key.space": "Leertaste",
"common.key.backspace": "Rücktaste",
"common.key.enter": "Eingabe",
"common.key.tab": "Tab",
"common.key.delete": "Entf",
"common.key.home": "Pos1",
"common.key.end": "Ende",
"common.key.pageUp": "Bild auf",
"common.key.pageDown": "Bild ab",
"common.key.insert": "Einfg",
"common.unknown": "unbekannt",
"error.page.circular": "[Zirkulär]",
"error.globalSDK.noServerAvailable": "Kein Server verfügbar",
"error.globalSDK.serverNotAvailable": "Server nicht verfügbar",
"error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden",
"error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden",
"error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden",
"error.childStore.storeCreateFailed": "Speicher konnte nicht erstellt werden",
"terminal.connectionLost.abnormalClose": "WebSocket abnormal geschlossen: {{code}}",
} satisfies Partial<Record<Keys, string>>

View File

@@ -264,7 +264,7 @@ export const dict = {
"prompt.popover.emptyResults": "No matching results",
"prompt.popover.emptyCommands": "No matching commands",
"prompt.dropzone.label": "Drop images, PDFs, or text files here",
"prompt.dropzone.label": "Drop images or PDFs here",
"prompt.dropzone.file.label": "Drop to @mention file",
"prompt.slash.badge.custom": "custom",
"prompt.slash.badge.skill": "skill",
@@ -278,8 +278,8 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.pasteUnsupported.title": "Unsupported paste",
"prompt.toast.pasteUnsupported.description": "Only images or PDFs can be pasted here.",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
@@ -306,10 +306,6 @@ export const dict = {
"dialog.directory.search.placeholder": "Search folders",
"dialog.directory.empty": "No folders found",
"app.server.unreachable": "Could not reach {{server}}",
"app.server.retrying": "Retrying automatically...",
"app.server.otherServers": "Other servers",
"dialog.server.title": "Servers",
"dialog.server.description": "Switch which OpenCode server this app connects to.",
"dialog.server.search.placeholder": "Search servers",
@@ -323,9 +319,7 @@ export const dict = {
"dialog.server.add.name": "Server name (optional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Username (optional)",
"dialog.server.add.usernamePlaceholder": "username",
"dialog.server.add.password": "Password (optional)",
"dialog.server.add.passwordPlaceholder": "password",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
@@ -341,7 +335,6 @@ export const dict = {
"dialog.server.menu.delete": "Delete",
"dialog.server.current": "Current Server",
"dialog.server.status.default": "Default",
"server.row.noUsername": "no username",
"dialog.project.edit.title": "Edit project",
"dialog.project.edit.name": "Name",
@@ -463,7 +456,6 @@ export const dict = {
"error.page.action.checking": "Checking...",
"error.page.action.checkUpdates": "Check for updates",
"error.page.action.updateTo": "Update to {{version}}",
"error.page.circular": "[Circular]",
"error.page.report.prefix": "Please report this error to the OpenCode team",
"error.page.report.discord": "on Discord",
"error.page.version": "Version: {{version}}",
@@ -472,12 +464,6 @@ export const dict = {
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
"error.globalSDK.noServerAvailable": "No server available",
"error.globalSDK.serverNotAvailable": "Server not available",
"error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata",
"error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon",
"error.childStore.storeCreateFailed": "Failed to create store",
"directory.error.invalidUrl": "Invalid directory in URL.",
"error.chain.unknown": "Unknown error",
@@ -526,10 +512,6 @@ export const dict = {
"session.review.loadingChanges": "Loading changes...",
"session.review.empty": "No changes in this session yet",
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
"session.review.noVcs.createGit.title": "Create a Git repository",
"session.review.noVcs.createGit.description": "Track, review, and undo changes in this project",
"session.review.noVcs.createGit.actionLoading": "Creating Git repository...",
"session.review.noVcs.createGit.action": "Create Git repository",
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
"session.review.noChanges": "No changes",
@@ -548,19 +530,6 @@ export const dict = {
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.followupDock.summary.one": "{{count}} queued message",
"session.followupDock.summary.other": "{{count}} queued messages",
"session.followupDock.sendNow": "Send now",
"session.followupDock.edit": "Edit",
"session.followupDock.collapse": "Collapse queued messages",
"session.followupDock.expand": "Expand queued messages",
"session.revertDock.summary.one": "{{count}} rolled back message",
"session.revertDock.summary.other": "{{count}} rolled back messages",
"session.revertDock.collapse": "Collapse rolled back messages",
"session.revertDock.expand": "Expand rolled back messages",
"session.revertDock.restore": "Restore message",
"session.new.title": "Build anything",
"session.new.worktree.main": "Main branch",
@@ -575,22 +544,6 @@ export const dict = {
"session.header.open.ariaLabel": "Open in {{app}}",
"session.header.open.menu": "Open options",
"session.header.open.copyPath": "Copy path",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",
"session.header.open.fileManager": "File Manager",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"status.popover.trigger": "Status",
"status.popover.ariaLabel": "Server configurations",
@@ -623,7 +576,6 @@ export const dict = {
"terminal.title.numbered": "Terminal {{number}}",
"terminal.close": "Close terminal",
"terminal.connectionLost.title": "Connection Lost",
"terminal.connectionLost.abnormalClose": "WebSocket closed abnormally: {{code}}",
"terminal.connectionLost.description":
"The terminal connection was interrupted. This can happen when the server restarts.",
@@ -641,21 +593,6 @@ export const dict = {
"common.edit": "Edit",
"common.loadMore": "Load more",
"common.key.esc": "ESC",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "unknown",
"common.time.justNow": "Just now",
"common.time.minutesAgo.short": "{{count}}m ago",
@@ -675,30 +612,6 @@ export const dict = {
"sidebar.project.viewAllSessions": "View all sessions",
"sidebar.project.clearNotifications": "Clear notifications",
"debugBar.ariaLabel": "Development performance diagnostics",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Last completed route transition touching a session page, measured from router start until the first paint after it settles.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Rolling frames per second over the last 5 seconds.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Worst frame time over the last 5 seconds.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Frames over 32ms in the last 5 seconds.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blocked time and long-task count in the last 5 seconds. Max task: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Worst observed input delay in the last 5 seconds.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Cumulative layout shift for the current app lifetime.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Used JS heap vs heap limit. Chromium only.",
"debugBar.mem.tip": "Used JS heap vs heap limit. {{used}} of {{limit}}.",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
@@ -720,16 +633,10 @@ export const dict = {
"settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.appearance.title": "Appearance",
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
"settings.general.row.colorScheme.title": "Color scheme",
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
"settings.general.row.theme.title": "Theme",
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Customise the mono font used in code blocks",
"settings.general.row.followup.title": "Follow-up behavior",
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
"settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",

View File

@@ -113,7 +113,6 @@ export const dict = {
"dialog.model.empty": "Sin resultados de modelos",
"dialog.model.manage": "Gestionar modelos",
"dialog.model.manage.description": "Personalizar qué modelos aparecen en el selector de modelos.",
"dialog.model.manage.provider.toggle": "Alternar todos los modelos de {{provider}}",
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos proporcionados por OpenCode",
"dialog.model.unpaid.addMore.title": "Añadir más modelos de proveedores populares",
@@ -263,7 +262,7 @@ export const dict = {
"prompt.popover.emptyResults": "Sin resultados coincidentes",
"prompt.popover.emptyCommands": "Sin comandos coincidentes",
"prompt.dropzone.label": "Suelta imágenes, PDFs o archivos de texto aquí",
"prompt.dropzone.label": "Suelta imágenes o PDFs aquí",
"prompt.dropzone.file.label": "Suelta para @mencionar archivo",
"prompt.slash.badge.custom": "personalizado",
"prompt.slash.badge.skill": "skill",
@@ -277,8 +276,8 @@ export const dict = {
"prompt.action.send": "Enviar",
"prompt.action.stop": "Detener",
"prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
"prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.",
"prompt.toast.pasteUnsupported.title": "Pegado no soportado",
"prompt.toast.pasteUnsupported.description": "Solo se pueden pegar imágenes o PDFs aquí.",
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
"prompt.toast.worktreeCreateFailed.title": "Fallo al crear el árbol de trabajo",
@@ -315,11 +314,6 @@ export const dict = {
"dialog.server.add.error": "No se pudo conectar al servidor",
"dialog.server.add.checking": "Comprobando...",
"dialog.server.add.button": "Añadir servidor",
"dialog.server.add.name": "Nombre del servidor (opcional)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nombre de usuario (opcional)",
"dialog.server.add.password": "Contraseña (opcional)",
"dialog.server.edit.title": "Editar servidor",
"dialog.server.default.title": "Servidor predeterminado",
"dialog.server.default.description":
"Conectar a este servidor al iniciar la app en lugar de iniciar un servidor local. Requiere reinicio.",
@@ -399,7 +393,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Idioma",
"toast.language.description": "Cambiado a {{language}}",
@@ -506,14 +499,10 @@ export const dict = {
"session.review.change.other": "Cambios",
"session.review.loadingChanges": "Cargando cambios...",
"session.review.empty": "No hay cambios en esta sesión aún",
"session.review.noVcs": "No se detectó Sistema de Control de Versiones Git, los cambios no se muestran",
"session.review.noSnapshot":
"El seguimiento de instantáneas está deshabilitado en la configuración, por lo que los cambios de sesión no están disponibles",
"session.review.noChanges": "Sin cambios",
"session.files.selectToOpen": "Selecciona un archivo para abrir",
"session.files.all": "Todos los archivos",
"session.files.empty": "Sin archivos",
"session.files.binaryContent": "Archivo binario (el contenido no puede ser mostrado)",
"session.messages.renderEarlier": "Renderizar mensajes anteriores",
@@ -526,17 +515,6 @@ export const dict = {
"session.todo.title": "Tareas",
"session.todo.collapse": "Contraer",
"session.todo.expand": "Expandir",
"session.followupDock.summary.one": "{{count}} mensaje en cola",
"session.followupDock.summary.other": "{{count}} mensajes en cola",
"session.followupDock.sendNow": "Enviar ahora",
"session.followupDock.edit": "Editar",
"session.followupDock.collapse": "Contraer mensajes en cola",
"session.followupDock.expand": "Expandir mensajes en cola",
"session.revertDock.summary.one": "{{count}} mensaje revertido",
"session.revertDock.summary.other": "{{count}} mensajes revertidos",
"session.revertDock.collapse": "Contraer mensajes revertidos",
"session.revertDock.expand": "Expandir mensajes revertidos",
"session.revertDock.restore": "Restaurar mensaje",
"session.new.title": "Construye lo que quieras",
"session.new.worktree.main": "Rama principal",
@@ -634,20 +612,11 @@ export const dict = {
"settings.general.row.language.description": "Cambiar el idioma de visualización para OpenCode",
"settings.general.row.appearance.title": "Apariencia",
"settings.general.row.appearance.description": "Personaliza cómo se ve OpenCode en tu dispositivo",
"settings.general.row.colorScheme.title": "Esquema de color",
"settings.general.row.colorScheme.description": "Elige si OpenCode sigue el tema del sistema, claro u oscuro",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
"settings.general.row.font.title": "Fuente",
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
"settings.general.row.followup.title": "Comportamiento de seguimiento",
"settings.general.row.followup.description":
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
"settings.general.row.followup.option.queue": "Cola",
"settings.general.row.followup.option.steer": "Dirigir",
"settings.general.row.reasoningSummaries.title": "Mostrar resúmenes de razonamiento",
"settings.general.row.reasoningSummaries.description":
"Mostrar resúmenes del razonamiento del modelo en la línea de tiempo",
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell",
"settings.general.row.shellToolPartsExpanded.description":
"Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo",
@@ -871,79 +840,4 @@ export const dict = {
"common.time.daysAgo.short": "hace {{count}} d",
"settings.providers.connected.environmentDescription": "Conectado desde tus variables de entorno",
"settings.providers.custom.description": "Añade un proveedor compatible con OpenAI por su URL base.",
"app.server.unreachable": "No se pudo conectar con {{server}}",
"app.server.retrying": "Reintentando automáticamente...",
"app.server.otherServers": "Otros servidores",
"dialog.server.add.usernamePlaceholder": "usuario",
"dialog.server.add.passwordPlaceholder": "contraseña",
"server.row.noUsername": "sin usuario",
"session.review.noVcs.createGit.title": "Crear repositorio Git",
"session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto",
"session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...",
"session.review.noVcs.createGit.action": "Crear repositorio Git",
"session.todo.progress": "{{done}} de {{total}} tareas completadas",
"session.question.progress": "{{current}} de {{total}} preguntas",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorador de archivos",
"session.header.open.fileManager": "Gestor de archivos",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Diagnóstico de rendimiento de desarrollo",
"debugBar.na": "n/d",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Última transición de ruta completada tocando una página de sesión, medida desde el inicio del router hasta el primer pintado después de asentarse.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Cuadros por segundo en los últimos 5 segundos.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Peor tiempo de cuadro en los últimos 5 segundos.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Cuadros superiores a 32ms en los últimos 5 segundos.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Tiempo bloqueado y recuento de tareas largas en los últimos 5 segundos. Tarea máx: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Peor retraso de entrada observado en los últimos 5 segundos.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Duración aproximada de la interacción en los últimos 5 segundos. Esto es similar a INP, no el INP oficial de Web Vitals.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Cambio de diseño acumulativo para la vida útil actual de la aplicación.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Heap JS usado vs límite de heap. Solo Chromium.",
"debugBar.mem.tip": "Heap JS usado vs límite de heap. {{used}} de {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Mayús",
"common.key.meta": "Meta",
"common.key.space": "Espacio",
"common.key.backspace": "Retroceso",
"common.key.enter": "Intro",
"common.key.tab": "Tab",
"common.key.delete": "Supr",
"common.key.home": "Inicio",
"common.key.end": "Fin",
"common.key.pageUp": "RePág",
"common.key.pageDown": "AvPág",
"common.key.insert": "Insert",
"common.unknown": "desconocido",
"error.page.circular": "[Circular]",
"error.globalSDK.noServerAvailable": "Ningún servidor disponible",
"error.globalSDK.serverNotAvailable": "Servidor no disponible",
"error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
"error.childStore.storeCreateFailed": "Error al crear almacén",
"terminal.connectionLost.abnormalClose": "WebSocket cerrado anormalmente: {{code}}",
}

View File

@@ -104,7 +104,6 @@ export const dict = {
"dialog.model.empty": "Aucun résultat de modèle",
"dialog.model.manage": "Gérer les modèles",
"dialog.model.manage.description": "Personnalisez les modèles qui apparaissent dans le sélecteur.",
"dialog.model.manage.provider.toggle": "Basculer tous les modèles {{provider}}",
"dialog.model.unpaid.freeModels.title": "Modèles gratuits fournis par OpenCode",
"dialog.model.unpaid.addMore.title": "Ajouter plus de modèles de fournisseurs populaires",
"dialog.provider.viewAll": "Voir plus de fournisseurs",
@@ -244,7 +243,7 @@ export const dict = {
"prompt.example.25": "Comment fonctionnent les variables d'environnement ici ?",
"prompt.popover.emptyResults": "Aucun résultat correspondant",
"prompt.popover.emptyCommands": "Aucune commande correspondante",
"prompt.dropzone.label": "Déposez des images, des PDF ou des fichiers texte ici",
"prompt.dropzone.label": "Déposez des images ou des PDF ici",
"prompt.dropzone.file.label": "Déposez pour @mentionner le fichier",
"prompt.slash.badge.custom": "personnalisé",
"prompt.slash.badge.skill": "skill",
@@ -257,9 +256,8 @@ export const dict = {
"prompt.attachment.remove": "Supprimer la pièce jointe",
"prompt.action.send": "Envoyer",
"prompt.action.stop": "Arrêter",
"prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
"prompt.toast.pasteUnsupported.description":
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
"prompt.toast.pasteUnsupported.title": "Collage non supporté",
"prompt.toast.pasteUnsupported.description": "Seules les images ou les PDF peuvent être collés ici.",
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
"prompt.toast.modelAgentRequired.description": "Choisissez un agent et un modèle avant d'envoyer un message.",
"prompt.toast.worktreeCreateFailed.title": "Échec de la création de l'arbre de travail",
@@ -290,11 +288,6 @@ export const dict = {
"dialog.server.add.error": "Impossible de se connecter au serveur",
"dialog.server.add.checking": "Vérification...",
"dialog.server.add.button": "Ajouter un serveur",
"dialog.server.add.name": "Nom du serveur (optionnel)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nom d'utilisateur (optionnel)",
"dialog.server.add.password": "Mot de passe (optionnel)",
"dialog.server.edit.title": "Modifier le serveur",
"dialog.server.default.title": "Serveur par défaut",
"dialog.server.default.description":
"Se connecter à ce serveur au lancement de l'application au lieu de démarrer un serveur local. Nécessite un redémarrage.",
@@ -367,7 +360,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Langue",
"toast.language.description": "Passé à {{language}}",
"toast.theme.title": "Thème changé",
@@ -459,12 +451,8 @@ export const dict = {
"session.review.loadingChanges": "Chargement des modifications...",
"session.review.empty": "Aucune modification dans cette session pour l'instant",
"session.review.noChanges": "Aucune modification",
"session.review.noVcs": "Aucun système de contrôle de version Git détecté, modifications non affichées",
"session.review.noSnapshot":
"Le suivi des instantanés est désactivé dans la configuration, les modifications de session sont donc indisponibles",
"session.files.selectToOpen": "Sélectionnez un fichier à ouvrir",
"session.files.all": "Tous les fichiers",
"session.files.empty": "Aucun fichier",
"session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)",
"session.messages.renderEarlier": "Afficher les messages précédents",
"session.messages.loadingEarlier": "Chargement des messages précédents...",
@@ -475,17 +463,6 @@ export const dict = {
"session.todo.title": "Tâches",
"session.todo.collapse": "Réduire",
"session.todo.expand": "Développer",
"session.followupDock.summary.one": "{{count}} message en file d'attente",
"session.followupDock.summary.other": "{{count}} messages en file d'attente",
"session.followupDock.sendNow": "Envoyer maintenant",
"session.followupDock.edit": "Modifier",
"session.followupDock.collapse": "Réduire les messages en file d'attente",
"session.followupDock.expand": "Développer les messages en file d'attente",
"session.revertDock.summary.one": "{{count}} message annulé",
"session.revertDock.summary.other": "{{count}} messages annulés",
"session.revertDock.collapse": "Réduire les messages annulés",
"session.revertDock.expand": "Développer les messages annulés",
"session.revertDock.restore": "Restaurer le message",
"session.new.title": "Créez ce que vous voulez",
"session.new.worktree.main": "Branche principale",
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
@@ -573,20 +550,10 @@ export const dict = {
"settings.general.row.language.description": "Changer la langue d'affichage pour OpenCode",
"settings.general.row.appearance.title": "Apparence",
"settings.general.row.appearance.description": "Personnaliser l'apparence d'OpenCode sur votre appareil",
"settings.general.row.colorScheme.title": "Schéma de couleurs",
"settings.general.row.colorScheme.description": "Choisissez si OpenCode suit le thème système, clair ou sombre",
"settings.general.row.theme.title": "Thème",
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
"settings.general.row.font.title": "Police",
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
"settings.general.row.followup.title": "Comportement de suivi",
"settings.general.row.followup.description":
"Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
"settings.general.row.followup.option.queue": "File d'attente",
"settings.general.row.followup.option.steer": "Diriger",
"settings.general.row.reasoningSummaries.title": "Afficher les résumés de raisonnement",
"settings.general.row.reasoningSummaries.description":
"Afficher les résumés de raisonnement du modèle dans la chronologie",
"settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell",
"settings.general.row.shellToolPartsExpanded.description":
"Afficher les parties de l'outil shell développées par défaut dans la chronologie",
@@ -797,81 +764,4 @@ export const dict = {
"common.time.daysAgo.short": "il y a {{count}}j",
"settings.providers.connected.environmentDescription": "Connecté à partir de vos variables d'environnement",
"settings.providers.custom.description": "Ajouter un fournisseur compatible avec OpenAI via l'URL de base.",
"app.server.unreachable": "Impossible de joindre {{server}}",
"app.server.retrying": "Nouvelle tentative automatique...",
"app.server.otherServers": "Autres serveurs",
"dialog.server.add.usernamePlaceholder": "nom d'utilisateur",
"dialog.server.add.passwordPlaceholder": "mot de passe",
"server.row.noUsername": "aucun nom d'utilisateur",
"session.review.noVcs.createGit.title": "Créer un dépôt Git",
"session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet",
"session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...",
"session.review.noVcs.createGit.action": "Créer un dépôt Git",
"session.todo.progress": "{{done}} tâches sur {{total}} terminées",
"session.question.progress": "{{current}} questions sur {{total}}",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Explorateur de fichiers",
"session.header.open.fileManager": "Gestionnaire de fichiers",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Diagnostics de performance de développement",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Dernière transition de route terminée touchant une page de session, mesurée du début du routeur jusqu'au premier affichage après stabilisation.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Images par seconde glissantes sur les 5 dernières secondes.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Pire temps d'image sur les 5 dernières secondes.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Images de plus de 32ms au cours des 5 dernières secondes.",
"debugBar.long.label": "LONG",
"debugBar.long.tip":
"Temps bloqué et nombre de tâches longues au cours des 5 dernières secondes. Tâche max : {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Pire délai d'entrée observé au cours des 5 dernières secondes.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Durée approximative d'interaction au cours des 5 dernières secondes. Ceci est similaire à INP, pas le INP officiel des Web Vitals.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Décalage cumulatif de la mise en page pour la durée de vie actuelle de l'application.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Tas JS utilisé vs limite de tas. Chromium uniquement.",
"debugBar.mem.tip": "Tas JS utilisé vs limite de tas. {{used}} sur {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Maj",
"common.key.meta": "Méta",
"common.key.space": "Espace",
"common.key.backspace": "Retour arrière",
"common.key.enter": "Entrée",
"common.key.tab": "Tab",
"common.key.delete": "Suppr",
"common.key.home": "Début",
"common.key.end": "Fin",
"common.key.pageUp": "Page précédente",
"common.key.pageDown": "Page suivante",
"common.key.insert": "Inser",
"common.unknown": "inconnu",
"error.page.circular": "[Circulaire]",
"error.globalSDK.noServerAvailable": "Aucun serveur disponible",
"error.globalSDK.serverNotAvailable": "Serveur non disponible",
"error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant",
"error.childStore.persistedProjectMetadataCreateFailed":
"Échec de la création des métadonnées de projet persistantes",
"error.childStore.persistedProjectIconCreateFailed": "Échec de la création de l'icône de projet persistante",
"error.childStore.storeCreateFailed": "Échec de la création du stockage",
"terminal.connectionLost.abnormalClose": "WebSocket fermé anormalement : {{code}}",
}

View File

@@ -104,7 +104,6 @@ export const dict = {
"dialog.model.empty": "モデルが見つかりません",
"dialog.model.manage": "モデルを管理",
"dialog.model.manage.description": "モデルセレクターに表示するモデルをカスタマイズします。",
"dialog.model.manage.provider.toggle": "すべての{{provider}}モデルを切り替え",
"dialog.model.unpaid.freeModels.title": "OpenCodeが提供する無料モデル",
"dialog.model.unpaid.addMore.title": "人気のプロバイダーからモデルを追加",
"dialog.provider.viewAll": "さらにプロバイダーを表示",
@@ -243,7 +242,7 @@ export const dict = {
"prompt.example.25": "ここでは環境変数はどう機能しますか?",
"prompt.popover.emptyResults": "一致する結果がありません",
"prompt.popover.emptyCommands": "一致するコマンドがありません",
"prompt.dropzone.label": "画像、PDF、またはテキストファイルをここにドロップしてください",
"prompt.dropzone.label": "画像またはPDFをここにドロップ",
"prompt.dropzone.file.label": "ドロップして@メンションファイルを追加",
"prompt.slash.badge.custom": "カスタム",
"prompt.slash.badge.skill": "スキル",
@@ -256,8 +255,8 @@ export const dict = {
"prompt.attachment.remove": "添付ファイルを削除",
"prompt.action.send": "送信",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル",
"prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
"prompt.toast.pasteUnsupported.title": "サポートされていない貼り付け",
"prompt.toast.pasteUnsupported.description": "ここでは画像またはPDFのみ貼り付け可能です。",
"prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
"prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",
"prompt.toast.worktreeCreateFailed.title": "ワークツリーの作成に失敗しました",
@@ -288,11 +287,6 @@ export const dict = {
"dialog.server.add.error": "サーバーに接続できませんでした",
"dialog.server.add.checking": "確認中...",
"dialog.server.add.button": "サーバーを追加",
"dialog.server.add.name": "サーバー名 (オプション)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "ユーザー名 (オプション)",
"dialog.server.add.password": "パスワード (オプション)",
"dialog.server.edit.title": "サーバーを編集",
"dialog.server.default.title": "デフォルトサーバー",
"dialog.server.default.description":
"ローカルサーバーを起動する代わりに、アプリ起動時にこのサーバーに接続します。再起動が必要です。",
@@ -364,7 +358,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "言語",
"toast.language.description": "{{language}}に切り替えました",
"toast.theme.title": "テーマが切り替わりました",
@@ -451,12 +444,9 @@ export const dict = {
"session.review.change.other": "変更",
"session.review.loadingChanges": "変更を読み込み中...",
"session.review.empty": "このセッションでの変更はまだありません",
"session.review.noVcs": "Gitバージョン管理システムが検出されないため、変更は表示されません",
"session.review.noSnapshot": "設定でスナップショット追跡が無効になっているため、セッションの変更は利用できません",
"session.review.noChanges": "変更なし",
"session.files.selectToOpen": "開くファイルを選択",
"session.files.all": "すべてのファイル",
"session.files.empty": "ファイルなし",
"session.files.binaryContent": "バイナリファイル(内容を表示できません)",
"session.messages.renderEarlier": "以前のメッセージを表示",
"session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
@@ -467,17 +457,6 @@ export const dict = {
"session.todo.title": "ToDo",
"session.todo.collapse": "折りたたむ",
"session.todo.expand": "展開",
"session.followupDock.summary.one": "{{count}} 件のメッセージが待機中",
"session.followupDock.summary.other": "{{count}} 件のメッセージが待機中",
"session.followupDock.sendNow": "今すぐ送信",
"session.followupDock.edit": "編集",
"session.followupDock.collapse": "待機中のメッセージを折りたたむ",
"session.followupDock.expand": "待機中のメッセージを展開",
"session.revertDock.summary.one": "{{count}} 件のロールバックされたメッセージ",
"session.revertDock.summary.other": "{{count}} 件のロールバックされたメッセージ",
"session.revertDock.collapse": "ロールバックされたメッセージを折りたたむ",
"session.revertDock.expand": "ロールバックされたメッセージを展開",
"session.revertDock.restore": "メッセージを復元",
"session.new.title": "何でも作る",
"session.new.worktree.main": "メインブランチ",
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
@@ -563,19 +542,10 @@ export const dict = {
"settings.general.row.language.description": "OpenCodeの表示言語を変更します",
"settings.general.row.appearance.title": "外観",
"settings.general.row.appearance.description": "デバイスでのOpenCodeの表示をカスタマイズします",
"settings.general.row.colorScheme.title": "配色",
"settings.general.row.colorScheme.description": "OpenCodeがシステム、ライト、またはダークテーマに従うかを選択します",
"settings.general.row.theme.title": "テーマ",
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
"settings.general.row.font.title": "フォント",
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
"settings.general.row.followup.title": "フォローアップの動作",
"settings.general.row.followup.description":
"フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します",
"settings.general.row.followup.option.queue": "キューに追加",
"settings.general.row.followup.option.steer": "即座に実行 (Steer)",
"settings.general.row.reasoningSummaries.title": "推論の要約を表示",
"settings.general.row.reasoningSummaries.description": "タイムラインにモデルの推論の要約を表示します",
"settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
"settings.general.row.shellToolPartsExpanded.description":
"タイムラインで shell ツールパーツをデフォルトで展開して表示します",
@@ -783,78 +753,4 @@ export const dict = {
"common.time.daysAgo.short": "{{count}} 日前",
"settings.providers.connected.environmentDescription": "環境変数から接続されました",
"settings.providers.custom.description": "ベース URL を指定して OpenAI 互換のプロバイダーを追加します。",
"app.server.unreachable": "{{server}} に到達できませんでした",
"app.server.retrying": "自動的に再試行中...",
"app.server.otherServers": "その他のサーバー",
"dialog.server.add.usernamePlaceholder": "ユーザー名",
"dialog.server.add.passwordPlaceholder": "パスワード",
"server.row.noUsername": "ユーザー名なし",
"session.review.noVcs.createGit.title": "Git リポジトリを作成",
"session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す",
"session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...",
"session.review.noVcs.createGit.action": "Git リポジトリを作成",
"session.todo.progress": "{{done}} 個中 {{total}} 個の Todo が完了",
"session.question.progress": "{{total}} 問中 {{current}} 問",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "エクスプローラー",
"session.header.open.fileManager": "ファイルマネージャー",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "ターミナル",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "開発パフォーマンス診断",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip": "セッションページに触れる最後に完了したルート遷移。ルーター開始から安定後の最初の描画まで測定。",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "過去5秒間のローリングフレーム/秒。",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "過去5秒間の最悪フレーム時間。",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "過去5秒間で32msを超えたフレーム。",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "過去5秒間のブロック時間と長時間タスク数。最大タスク: {{max}}。",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "過去5秒間で観測された最悪の入力遅延。",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"過去5秒間の概算インタラクション時間。これは INP に似ていますが、公式の Web Vitals INP ではありません。",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "現在のアプリ寿命の累積レイアウトシフト。",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "使用中の JS ヒープ対ヒープ制限。Chromium のみ。",
"debugBar.mem.tip": "使用中の JS ヒープ対ヒープ制限。{{limit}} 中 {{used}}。",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "不明",
"error.page.circular": "[循環]",
"error.globalSDK.noServerAvailable": "利用可能なサーバーがありません",
"error.globalSDK.serverNotAvailable": "サーバーが利用できません",
"error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました",
"error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました",
"error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました",
"error.childStore.storeCreateFailed": "ストアの作成に失敗しました",
"terminal.connectionLost.abnormalClose": "WebSocket が異常終了しました: {{code}}",
}

View File

@@ -108,7 +108,6 @@ export const dict = {
"dialog.model.empty": "모델 결과 없음",
"dialog.model.manage": "모델 관리",
"dialog.model.manage.description": "모델 선택기에 표시할 모델 사용자 지정",
"dialog.model.manage.provider.toggle": "모든 {{provider}} 모델 토글",
"dialog.model.unpaid.freeModels.title": "OpenCode에서 제공하는 무료 모델",
"dialog.model.unpaid.addMore.title": "인기 공급자의 모델 추가",
"dialog.provider.viewAll": "더 많은 공급자 보기",
@@ -247,7 +246,7 @@ export const dict = {
"prompt.example.25": "여기서 환경 변수는 어떻게 작동하나요?",
"prompt.popover.emptyResults": "일치하는 결과 없음",
"prompt.popover.emptyCommands": "일치하는 명령어 없음",
"prompt.dropzone.label": "이미지, PDF 또는 텍스트 파일을 이곳에 드롭하세요",
"prompt.dropzone.label": "이미지 PDF를 여기에 드롭하세요",
"prompt.dropzone.file.label": "드롭하여 파일 @멘션 추가",
"prompt.slash.badge.custom": "사용자 지정",
"prompt.slash.badge.skill": "스킬",
@@ -260,8 +259,8 @@ export const dict = {
"prompt.attachment.remove": "첨부 파일 제거",
"prompt.action.send": "전송",
"prompt.action.stop": "중지",
"prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일",
"prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
"prompt.toast.pasteUnsupported.title": "지원되지 않는 붙여넣기",
"prompt.toast.pasteUnsupported.description": "이미지 PDF만 붙여넣을 수 있습니다.",
"prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
"prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",
"prompt.toast.worktreeCreateFailed.title": "작업 트리 생성 실패",
@@ -292,11 +291,6 @@ export const dict = {
"dialog.server.add.error": "서버에 연결할 수 없습니다",
"dialog.server.add.checking": "확인 중...",
"dialog.server.add.button": "서버 추가",
"dialog.server.add.name": "서버 이름 (선택 사항)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "사용자 이름 (선택 사항)",
"dialog.server.add.password": "비밀번호 (선택 사항)",
"dialog.server.edit.title": "서버 편집",
"dialog.server.default.title": "기본 서버",
"dialog.server.default.description":
"로컬 서버를 시작하는 대신 앱 실행 시 이 서버에 연결합니다. 다시 시작해야 합니다.",
@@ -367,7 +361,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "언어",
"toast.language.description": "{{language}}(으)로 전환됨",
"toast.theme.title": "테마 전환됨",
@@ -453,12 +446,9 @@ export const dict = {
"session.review.change.other": "변경",
"session.review.loadingChanges": "변경 사항 로드 중...",
"session.review.empty": "이 세션에 변경 사항이 아직 없습니다",
"session.review.noVcs": "Git 버전 관리 시스템이 감지되지 않아 변경 사항이 표시되지 않습니다",
"session.review.noSnapshot": "구성에서 스냅샷 추적이 비활성화되어 있어 세션 변경 사항을 사용할 수 없습니다",
"session.review.noChanges": "변경 없음",
"session.files.selectToOpen": "열 파일을 선택하세요",
"session.files.all": "모든 파일",
"session.files.empty": "파일 없음",
"session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)",
"session.messages.renderEarlier": "이전 메시지 렌더링",
"session.messages.loadingEarlier": "이전 메시지 로드 중...",
@@ -469,17 +459,6 @@ export const dict = {
"session.todo.title": "할 일",
"session.todo.collapse": "접기",
"session.todo.expand": "펼치기",
"session.followupDock.summary.one": "{{count}}개의 대기 중인 메시지",
"session.followupDock.summary.other": "{{count}}개의 대기 중인 메시지",
"session.followupDock.sendNow": "지금 전송",
"session.followupDock.edit": "편집",
"session.followupDock.collapse": "대기 중인 메시지 접기",
"session.followupDock.expand": "대기 중인 메시지 펼치기",
"session.revertDock.summary.one": "{{count}}개의 롤백된 메시지",
"session.revertDock.summary.other": "{{count}}개의 롤백된 메시지",
"session.revertDock.collapse": "롤백된 메시지 접기",
"session.revertDock.expand": "롤백된 메시지 펼치기",
"session.revertDock.restore": "메시지 복원",
"session.new.title": "무엇이든 만들기",
"session.new.worktree.main": "메인 브랜치",
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
@@ -564,18 +543,10 @@ export const dict = {
"settings.general.row.language.description": "OpenCode 표시 언어 변경",
"settings.general.row.appearance.title": "모양",
"settings.general.row.appearance.description": "기기에서 OpenCode가 보이는 방식 사용자 지정",
"settings.general.row.colorScheme.title": "색상 테마",
"settings.general.row.colorScheme.description": "OpenCode가 시스템, 라이트 또는 다크 테마를 따를지 선택하세요",
"settings.general.row.theme.title": "테마",
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
"settings.general.row.font.title": "글꼴",
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
"settings.general.row.followup.title": "후속 조치 동작",
"settings.general.row.followup.description": "후속 프롬프트를 즉시 실행할지 대기열에 넣을지 선택하세요",
"settings.general.row.followup.option.queue": "대기열",
"settings.general.row.followup.option.steer": "조종",
"settings.general.row.reasoningSummaries.title": "추론 요약 표시",
"settings.general.row.reasoningSummaries.description": "타임라인에 모델 추론 요약 표시",
"settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기",
"settings.general.row.shellToolPartsExpanded.description":
"타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다",
@@ -782,78 +753,4 @@ export const dict = {
"common.time.daysAgo.short": "{{count}}일 전",
"settings.providers.connected.environmentDescription": "환경 변수에서 연결됨",
"settings.providers.custom.description": "기본 URL로 OpenAI 호환 공급자를 추가합니다.",
"app.server.unreachable": "{{server}}에 연결할 수 없습니다",
"app.server.retrying": "자동으로 재시도 중...",
"app.server.otherServers": "다른 서버",
"dialog.server.add.usernamePlaceholder": "사용자 이름",
"dialog.server.add.passwordPlaceholder": "비밀번호",
"server.row.noUsername": "사용자 이름 없음",
"session.review.noVcs.createGit.title": "Git 저장소 생성",
"session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소",
"session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...",
"session.review.noVcs.createGit.action": "Git 저장소 생성",
"session.todo.progress": "{{total}}개의 할 일 중 {{done}}개 완료",
"session.question.progress": "{{total}}개의 질문 중 {{current}}개",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "파일 탐색기",
"session.header.open.fileManager": "파일 관리자",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "터미널",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "개발 성능 진단",
"debugBar.na": "해당 없음",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"세션 페이지에 닿은 마지막 완료된 라우트 전환. 라우터 시작부터 정착 후 첫 번째 페인트까지 측정됨.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "지난 5초간의 초당 프레임 수.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "지난 5초간의 최악의 프레임 시간.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "지난 5초간 32ms를 초과한 프레임.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "지난 5초간의 차단된 시간 및 긴 작업 수. 최대 작업: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "지난 5초간 관찰된 최악의 입력 지연.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip": "지난 5초간의 대략적인 상호작용 지속 시간. 이것은 공식 Web Vitals INP가 아닌 INP와 유사합니다.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "현재 앱 수명 동안의 누적 레이아웃 이동.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "사용된 JS 힙 대 힙 제한. Chromium 전용.",
"debugBar.mem.tip": "사용된 JS 힙 대 힙 제한. {{limit}} 중 {{used}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "알 수 없음",
"error.page.circular": "[순환]",
"error.globalSDK.noServerAvailable": "사용 가능한 서버 없음",
"error.globalSDK.serverNotAvailable": "서버를 사용할 수 없음",
"error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패",
"error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패",
"error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패",
"error.childStore.storeCreateFailed": "저장소 생성 실패",
"terminal.connectionLost.abnormalClose": "WebSocket이 비정상적으로 닫힘: {{code}}",
}

View File

@@ -116,7 +116,6 @@ export const dict = {
"dialog.model.empty": "Ingen modellresultater",
"dialog.model.manage": "Administrer modeller",
"dialog.model.manage.description": "Tilpass hvilke modeller som vises i modellvelgeren.",
"dialog.model.manage.provider.toggle": "Veksle alle {{provider}}-modeller",
"dialog.model.unpaid.freeModels.title": "Gratis modeller levert av OpenCode",
"dialog.model.unpaid.addMore.title": "Legg til flere modeller fra populære leverandører",
@@ -217,7 +216,7 @@ export const dict = {
"common.search.placeholder": "Søk",
"common.goBack": "Gå tilbake",
"common.goForward": "Gå frem",
"common.goForward": "Navigate forward",
"common.loading": "Laster",
"common.loading.ellipsis": "...",
"common.cancel": "Avbryt",
@@ -266,7 +265,7 @@ export const dict = {
"prompt.popover.emptyResults": "Ingen matchende resultater",
"prompt.popover.emptyCommands": "Ingen matchende kommandoer",
"prompt.dropzone.label": "Slipp bilder, PDF-er eller tekstfiler her",
"prompt.dropzone.label": "Slipp bilder eller PDF-er her",
"prompt.dropzone.file.label": "Slipp for å @nevne fil",
"prompt.slash.badge.custom": "egendefinert",
"prompt.slash.badge.skill": "skill",
@@ -280,8 +279,8 @@ export const dict = {
"prompt.action.send": "Send",
"prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
"prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
"prompt.toast.pasteUnsupported.title": "Liming ikke støttet",
"prompt.toast.pasteUnsupported.description": "Kun bilder eller PDF-er kan limes inn her.",
"prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
"prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",
"prompt.toast.worktreeCreateFailed.title": "Kunne ikke opprette worktree",
@@ -318,11 +317,6 @@ export const dict = {
"dialog.server.add.error": "Kunne ikke koble til server",
"dialog.server.add.checking": "Sjekker...",
"dialog.server.add.button": "Legg til server",
"dialog.server.add.name": "Servernavn (valgfritt)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Brukernavn (valgfritt)",
"dialog.server.add.password": "Passord (valgfritt)",
"dialog.server.edit.title": "Rediger server",
"dialog.server.default.title": "Standardserver",
"dialog.server.default.description":
"Koble til denne serveren ved oppstart i stedet for å starte en lokal server. Krever omstart.",
@@ -400,7 +394,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Språk",
"toast.language.description": "Byttet til {{language}}",
@@ -506,14 +499,10 @@ export const dict = {
"session.review.change.other": "Endringer",
"session.review.loadingChanges": "Laster endringer...",
"session.review.empty": "Ingen endringer i denne sesjonen ennå",
"session.review.noVcs": "Ingen Git-versjonskontrollsystem oppdaget, endringer vises ikke",
"session.review.noSnapshot":
"Snapshot-sporing er deaktivert i konfigurasjonen, så sesjonsendringer er ikke tilgjengelige",
"session.review.noChanges": "Ingen endringer",
"session.files.selectToOpen": "Velg en fil å åpne",
"session.files.all": "Alle filer",
"session.files.empty": "Ingen filer",
"session.files.binaryContent": "Binær fil (innhold kan ikke vises)",
"session.messages.renderEarlier": "Vis tidligere meldinger",
@@ -526,17 +515,6 @@ export const dict = {
"session.todo.title": "Oppgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Utvid",
"session.followupDock.summary.one": "{{count}} melding i kø",
"session.followupDock.summary.other": "{{count}} meldinger i kø",
"session.followupDock.sendNow": "Send nå",
"session.followupDock.edit": "Rediger",
"session.followupDock.collapse": "Skjul meldinger i kø",
"session.followupDock.expand": "Utvid meldinger i kø",
"session.revertDock.summary.one": "{{count}} tilbakestilt melding",
"session.revertDock.summary.other": "{{count}} tilbakestilte meldinger",
"session.revertDock.collapse": "Skjul tilbakestilte meldinger",
"session.revertDock.expand": "Utvid tilbakestilte meldinger",
"session.revertDock.restore": "Gjenopprett melding",
"session.new.title": "Bygg hva som helst",
"session.new.worktree.main": "Hovedgren",
@@ -634,18 +612,11 @@ export const dict = {
"settings.general.row.language.description": "Endre visningsspråket for OpenCode",
"settings.general.row.appearance.title": "Utseende",
"settings.general.row.appearance.description": "Tilpass hvordan OpenCode ser ut på enheten din",
"settings.general.row.colorScheme.title": "Fargevalg",
"settings.general.row.colorScheme.description": "Velg om OpenCode skal følge systemets, lyst eller mørkt tema",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
"settings.general.row.font.title": "Skrift",
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
"settings.general.row.followup.title": "Oppfølgingsadferd",
"settings.general.row.followup.description": "Velg om oppfølgingsspørsmål skal kjøres umiddelbart eller vente i kø",
"settings.general.row.followup.option.queue": "Kø",
"settings.general.row.followup.option.steer": "Styr",
"settings.general.row.reasoningSummaries.title": "Vis resonneringssammendrag",
"settings.general.row.reasoningSummaries.description": "Vis sammendrag av modellresonnering i tidslinjen",
"settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler",
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen",
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
@@ -865,79 +836,4 @@ export const dict = {
"common.time.daysAgo.short": "{{count}} d siden",
"settings.providers.connected.environmentDescription": "Koblet til fra miljøvariablene dine",
"settings.providers.custom.description": "Legg til en OpenAI-kompatibel leverandør via basis-URL.",
"app.server.unreachable": "Kunne ikke nå {{server}}",
"app.server.retrying": "Prøver på nytt automatisk...",
"app.server.otherServers": "Andre servere",
"dialog.server.add.usernamePlaceholder": "brukernavn",
"dialog.server.add.passwordPlaceholder": "passord",
"server.row.noUsername": "inget brukernavn",
"session.review.noVcs.createGit.title": "Opprett et Git-depot",
"session.review.noVcs.createGit.description": "Spor, gjennomgå og angre endringer i dette prosjektet",
"session.review.noVcs.createGit.actionLoading": "Oppretter Git-depot...",
"session.review.noVcs.createGit.action": "Opprett Git-depot",
"session.todo.progress": "{{done}} av {{total}} oppgaver fullført",
"session.question.progress": "{{current}} av {{total}} spørsmål",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Filutforsker",
"session.header.open.fileManager": "Filbehandler",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Utviklingsytelsesdiagnostikk",
"debugBar.na": "i/t",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Siste fullførte ruteovergang som berører en sesjonsside, målt fra ruterstart til første opptegning etter at den har roet seg.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Rullende bilder per sekund over de siste 5 sekundene.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Verste bildetid over de siste 5 sekundene.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Bilder over 32ms i de siste 5 sekundene.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Blokkert tid og antall lange oppgaver i de siste 5 sekundene. Maks oppgave: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Verste observerte inndataforsinkelse i de siste 5 sekundene.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Omtrentlig interaksjonsvarighet over de siste 5 sekundene. Dette er INP-lignende, ikke den offisielle Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Kumulativ layoutforskyvning for gjeldende app-levetid.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Brukt JS-heap vs heap-grense. Kun Chromium.",
"debugBar.mem.tip": "Brukt JS-heap vs heap-grense. {{used}} av {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Mellomrom",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "ukjent",
"error.page.circular": "[Sirkulær]",
"error.globalSDK.noServerAvailable": "Ingen server tilgjengelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgjengelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon",
"error.childStore.storeCreateFailed": "Kunne ikke opprette lager",
"terminal.connectionLost.abnormalClose": "WebSocket lukket unormalt: {{code}}",
} satisfies Partial<Record<Keys, string>>

View File

@@ -104,7 +104,6 @@ export const dict = {
"dialog.model.empty": "Brak wyników modelu",
"dialog.model.manage": "Zarządzaj modelami",
"dialog.model.manage.description": "Dostosuj, które modele pojawiają się w wyborze modelu.",
"dialog.model.manage.provider.toggle": "Przełącz wszystkie modele {{provider}}",
"dialog.model.unpaid.freeModels.title": "Darmowe modele dostarczane przez OpenCode",
"dialog.model.unpaid.addMore.title": "Dodaj więcej modeli od popularnych dostawców",
"dialog.provider.viewAll": "Zobacz więcej dostawców",
@@ -245,7 +244,7 @@ export const dict = {
"prompt.example.25": "Jak działają tutaj zmienne środowiskowe?",
"prompt.popover.emptyResults": "Brak pasujących wyników",
"prompt.popover.emptyCommands": "Brak pasujących poleceń",
"prompt.dropzone.label": "Upuść tutaj obrazy, pliki PDF lub pliki tekstowe",
"prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj",
"prompt.dropzone.file.label": "Upuść, aby @wspomnieć plik",
"prompt.slash.badge.custom": "własne",
"prompt.slash.badge.skill": "skill",
@@ -258,8 +257,8 @@ export const dict = {
"prompt.attachment.remove": "Usuń załącznik",
"prompt.action.send": "Wyślij",
"prompt.action.stop": "Zatrzymaj",
"prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
"prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
"prompt.toast.pasteUnsupported.title": "Nieobsługiwane wklejanie",
"prompt.toast.pasteUnsupported.description": "Tylko obrazy lub pliki PDF mogą być tutaj wklejane.",
"prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
"prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",
"prompt.toast.worktreeCreateFailed.title": "Nie udało się utworzyć drzewa roboczego",
@@ -290,11 +289,6 @@ export const dict = {
"dialog.server.add.error": "Nie można połączyć się z serwerem",
"dialog.server.add.checking": "Sprawdzanie...",
"dialog.server.add.button": "Dodaj serwer",
"dialog.server.add.name": "Nazwa serwera (opcjonalnie)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Nazwa użytkownika (opcjonalnie)",
"dialog.server.add.password": "Hasło (opcjonalnie)",
"dialog.server.edit.title": "Edytuj serwer",
"dialog.server.default.title": "Domyślny serwer",
"dialog.server.default.description":
"Połącz z tym serwerem przy uruchomieniu aplikacji zamiast uruchamiać lokalny serwer. Wymaga restartu.",
@@ -365,7 +359,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Język",
"toast.language.description": "Przełączono na {{language}}",
"toast.theme.title": "Przełączono motyw",
@@ -452,12 +445,9 @@ export const dict = {
"session.review.change.other": "Zmiany",
"session.review.loadingChanges": "Ładowanie zmian...",
"session.review.empty": "Brak zmian w tej sesji",
"session.review.noVcs": "Nie wykryto systemu kontroli wersji Git, zmiany nie są wyświetlane",
"session.review.noSnapshot": "Śledzenie migawek jest wyłączone w konfiguracji, więc zmiany w sesji są niedostępne",
"session.review.noChanges": "Brak zmian",
"session.files.selectToOpen": "Wybierz plik do otwarcia",
"session.files.all": "Wszystkie pliki",
"session.files.empty": "Brak plików",
"session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)",
"session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości",
"session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
@@ -468,17 +458,6 @@ export const dict = {
"session.todo.title": "Zadania",
"session.todo.collapse": "Zwiń",
"session.todo.expand": "Rozwiń",
"session.followupDock.summary.one": "{{count}} wiadomość w kolejce",
"session.followupDock.summary.other": "{{count}} wiadomości w kolejce",
"session.followupDock.sendNow": "Wyślij teraz",
"session.followupDock.edit": "Edytuj",
"session.followupDock.collapse": "Zwiń wiadomości w kolejce",
"session.followupDock.expand": "Rozwiń wiadomości w kolejce",
"session.revertDock.summary.one": "{{count}} cofnięta wiadomość",
"session.revertDock.summary.other": "{{count}} cofnięte wiadomości",
"session.revertDock.collapse": "Zwiń cofnięte wiadomości",
"session.revertDock.expand": "Rozwiń cofnięte wiadomości",
"session.revertDock.restore": "Przywróć wiadomość",
"session.new.title": "Zbuduj cokolwiek",
"session.new.worktree.main": "Główna gałąź",
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
@@ -564,19 +543,10 @@ export const dict = {
"settings.general.row.language.description": "Zmień język wyświetlania dla OpenCode",
"settings.general.row.appearance.title": "Wygląd",
"settings.general.row.appearance.description": "Dostosuj wygląd OpenCode na swoim urządzeniu",
"settings.general.row.colorScheme.title": "Schemat kolorów",
"settings.general.row.colorScheme.description":
"Wybierz, czy OpenCode ma używać motywu systemowego, jasnego czy ciemnego",
"settings.general.row.theme.title": "Motyw",
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
"settings.general.row.font.title": "Czcionka",
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
"settings.general.row.followup.title": "Zachowanie kontynuacji",
"settings.general.row.followup.description": "Wybierz, czy kontynuacja ma być natychmiastowa, czy czekać w kolejce",
"settings.general.row.followup.option.queue": "Kolejka",
"settings.general.row.followup.option.steer": "Sterowanie",
"settings.general.row.reasoningSummaries.title": "Pokaż podsumowania wnioskowania",
"settings.general.row.reasoningSummaries.description": "Wyświetlaj podsumowania wnioskowania modelu na osi czasu",
"settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
"settings.general.row.shellToolPartsExpanded.description":
"Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu",
@@ -785,80 +755,4 @@ export const dict = {
"common.time.daysAgo.short": "{{count}} dni temu",
"settings.providers.connected.environmentDescription": "Połączono ze zmiennymi środowiskowymi",
"settings.providers.custom.description": "Dodaj dostawcę zgodnego z OpenAI poprzez podstawowy URL.",
"app.server.unreachable": "Nie można połączyć z {{server}}",
"app.server.retrying": "Ponawianie automatycznie...",
"app.server.otherServers": "Inne serwery",
"dialog.server.add.usernamePlaceholder": "nazwa użytkownika",
"dialog.server.add.passwordPlaceholder": "hasło",
"server.row.noUsername": "brak nazwy użytkownika",
"session.review.noVcs.createGit.title": "Utwórz repozytorium Git",
"session.review.noVcs.createGit.description": "Śledź, przeglądaj i cofaj zmiany w tym projekcie",
"session.review.noVcs.createGit.actionLoading": "Tworzenie repozytorium Git...",
"session.review.noVcs.createGit.action": "Utwórz repozytorium Git",
"session.todo.progress": "Ukończono {{done}} z {{total}} zadań",
"session.question.progress": "{{current}} z {{total}} pytań",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Eksplorator plików",
"session.header.open.fileManager": "Menedżer plików",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Diagnostyka wydajności deweloperskiej",
"debugBar.na": "n.d.",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Ostatnie zakończone przejście trasy dotykające strony sesji, mierzone od startu routera do pierwszego odrysowania po ustaleniu.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Średnia liczba klatek na sekundę w ciągu ostatnich 5 sekund.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Najgorszy czas klatki w ciągu ostatnich 5 sekund.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Klatki powyżej 32ms w ciągu ostatnich 5 sekund.",
"debugBar.long.label": "LONG",
"debugBar.long.tip":
"Zablokowany czas i liczba długich zadań w ciągu ostatnich 5 sekund. Maksymalne zadanie: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Najgorsze zaobserwowane opóźnienie wejścia w ciągu ostatnich 5 sekund.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Przybliżony czas trwania interakcji w ciągu ostatnich 5 sekund. Jest to podobne do INP, a nie oficjalne Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Skumulowane przesunięcie układu dla bieżącego czasu życia aplikacji.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Użyta sterta JS vs limit sterty. Tylko Chromium.",
"debugBar.mem.tip": "Użyta sterta JS vs limit sterty. {{used}} z {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Spacja",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "nieznany",
"error.page.circular": "[Cykliczne]",
"error.globalSDK.noServerAvailable": "Brak dostępnego serwera",
"error.globalSDK.serverNotAvailable": "Serwer niedostępny",
"error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej",
"error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu",
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
"error.childStore.storeCreateFailed": "Nie udało się utworzyć magazynu",
"terminal.connectionLost.abnormalClose": "WebSocket zamknięty nieprawidłowo: {{code}}",
}

View File

@@ -113,7 +113,6 @@ export const dict = {
"dialog.model.empty": "Модели не найдены",
"dialog.model.manage": "Управление моделями",
"dialog.model.manage.description": "Настройте какие модели появляются в выборе модели",
"dialog.model.manage.provider.toggle": "Переключить все модели {{provider}}",
"dialog.model.unpaid.freeModels.title": "Бесплатные модели от OpenCode",
"dialog.model.unpaid.addMore.title": "Добавьте больше моделей от популярных провайдеров",
@@ -263,7 +262,7 @@ export const dict = {
"prompt.popover.emptyResults": "Нет совпадений",
"prompt.popover.emptyCommands": "Нет совпадающих команд",
"prompt.dropzone.label": "Перетащите сюда изображения, PDF или текстовые файлы",
"prompt.dropzone.label": "Перетащите изображения или PDF сюда",
"prompt.dropzone.file.label": "Отпустите для @упоминания файла",
"prompt.slash.badge.custom": "своё",
"prompt.slash.badge.skill": "навык",
@@ -277,8 +276,8 @@ export const dict = {
"prompt.action.send": "Отправить",
"prompt.action.stop": "Остановить",
"prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
"prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
"prompt.toast.pasteUnsupported.title": "Неподдерживаемая вставка",
"prompt.toast.pasteUnsupported.description": "Сюда можно вставлять только изображения или PDF.",
"prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
"prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",
"prompt.toast.worktreeCreateFailed.title": "Не удалось создать worktree",
@@ -315,11 +314,6 @@ export const dict = {
"dialog.server.add.error": "Не удалось подключиться к серверу",
"dialog.server.add.checking": "Проверка...",
"dialog.server.add.button": "Добавить сервер",
"dialog.server.add.name": "Имя сервера (необязательно)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Имя пользователя (необязательно)",
"dialog.server.add.password": "Пароль (необязательно)",
"dialog.server.edit.title": "Редактировать сервер",
"dialog.server.default.title": "Сервер по умолчанию",
"dialog.server.default.description":
"Подключаться к этому серверу при запуске приложения вместо запуска локального сервера. Требуется перезапуск.",
@@ -399,7 +393,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Язык",
"toast.language.description": "Переключено на {{language}}",
@@ -506,12 +499,9 @@ export const dict = {
"session.review.change.other": "Изменения",
"session.review.loadingChanges": "Загрузка изменений...",
"session.review.empty": "Изменений в этой сессии пока нет",
"session.review.noVcs": "Система контроля версий Git не обнаружена, изменения не отображаются",
"session.review.noSnapshot": "Отслеживание снимков отключено в настройках, поэтому изменения сессии недоступны",
"session.review.noChanges": "Нет изменений",
"session.files.selectToOpen": "Выберите файл, чтобы открыть",
"session.files.all": "Все файлы",
"session.files.empty": "Нет файлов",
"session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)",
"session.messages.renderEarlier": "Показать предыдущие сообщения",
"session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
@@ -523,17 +513,6 @@ export const dict = {
"session.todo.title": "Задачи",
"session.todo.collapse": "Свернуть",
"session.todo.expand": "Развернуть",
"session.followupDock.summary.one": "{{count}} сообщение в очереди",
"session.followupDock.summary.other": "{{count}} сообщений в очереди",
"session.followupDock.sendNow": "Отправить сейчас",
"session.followupDock.edit": "Редактировать",
"session.followupDock.collapse": "Свернуть сообщения в очереди",
"session.followupDock.expand": "Развернуть сообщения в очереди",
"session.revertDock.summary.one": "{{count}} сообщение возвращено",
"session.revertDock.summary.other": "{{count}} сообщений возвращено",
"session.revertDock.collapse": "Свернуть возвращённые сообщения",
"session.revertDock.expand": "Развернуть возвращённые сообщения",
"session.revertDock.restore": "Восстановить сообщение",
"session.new.title": "Создавайте что угодно",
"session.new.worktree.main": "Основная ветка",
@@ -631,19 +610,10 @@ export const dict = {
"settings.general.row.language.description": "Изменить язык отображения OpenCode",
"settings.general.row.appearance.title": "Внешний вид",
"settings.general.row.appearance.description": "Настройте как OpenCode выглядит на вашем устройстве",
"settings.general.row.colorScheme.title": "Цветовая схема",
"settings.general.row.colorScheme.description": "Выберите, следует ли OpenCode системной, светлой или тёмной теме",
"settings.general.row.theme.title": "Тема",
"settings.general.row.theme.description": "Настройте оформление OpenCode.",
"settings.general.row.font.title": "Шрифт",
"settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
"settings.general.row.followup.description":
"Выберите, отправлять ли уточняющие вопросы сразу или помещать их в очередь",
"settings.general.row.followup.option.queue": "Очередь",
"settings.general.row.followup.option.steer": "Направлять",
"settings.general.row.reasoningSummaries.title": "Показывать сводки рассуждений",
"settings.general.row.reasoningSummaries.description": "Отображать сводки рассуждений модели в ленте",
"settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell",
"settings.general.row.shellToolPartsExpanded.description":
@@ -797,31 +767,30 @@ export const dict = {
"settings.permissions.tool.glob.description": "Сопоставление файлов по паттернам glob",
"settings.permissions.tool.grep.title": "Grep",
"settings.permissions.tool.grep.description": "Поиск по содержимому файлов с использованием регулярных выражений",
"settings.permissions.tool.list.title": "List",
"settings.permissions.tool.list.title": "Список",
"settings.permissions.tool.list.description": "Список файлов в директории",
"settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "Запуск команд оболочки",
"settings.permissions.tool.bash.description": "Выполнение команд оболочки",
"settings.permissions.tool.task.title": "Task",
"settings.permissions.tool.task.description": "Запуск подагентов",
"settings.permissions.tool.task.description": "Запуск под-агентов",
"settings.permissions.tool.skill.title": "Skill",
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
"settings.permissions.tool.skill.description": "Загрузить навык по имени",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
"settings.permissions.tool.todoread.title": "Todo Read",
"settings.permissions.tool.lsp.description": "Выполнение запросов к языковому серверу",
"settings.permissions.tool.todoread.title": "Чтение списка задач",
"settings.permissions.tool.todoread.description": "Чтение списка задач",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.title": "Запись списка задач",
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
"settings.permissions.tool.webfetch.title": "Web Fetch",
"settings.permissions.tool.webfetch.description": "Получение контента по URL",
"settings.permissions.tool.webfetch.description": "Получить содержимое по URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Поиск в интернете",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.title": "Поиск кода",
"settings.permissions.tool.codesearch.description": "Поиск кода в интернете",
"settings.permissions.tool.external_directory.title": "Внешняя директория",
"settings.permissions.tool.external_directory.description": "Доступ к файлам вне директории проекта",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description":
"Обнаружение повторяющихся вызовов инструментов с одинаковыми входными данными",
"settings.permissions.tool.doom_loop.description": "Обнаружение повторных вызовов инструментов с одинаковым вводом",
"session.delete.failed.title": "Не удалось удалить сессию",
"session.delete.title": "Удалить сессию",
@@ -838,21 +807,21 @@ export const dict = {
"workspace.reset.failed.title": "Не удалось сбросить рабочее пространство",
"workspace.reset.success.title": "Рабочее пространство сброшено",
"workspace.reset.success.description": "Рабочее пространство теперь соответствует ветке по умолчанию.",
"workspace.error.stillPreparing": "Рабочее пространство всё ещё подготавливается",
"workspace.status.checking": "Проверка незафиксированных изменений...",
"workspace.error.stillPreparing": "Рабочее пространство всё ещё готовится",
"workspace.status.checking": "Проверка наличия неслитых изменений...",
"workspace.status.error": "Не удалось проверить статус git.",
"workspace.status.clean": "Незафиксированных изменений не обнаружено.",
"workspace.status.dirty": "В этом рабочем пространстве обнаружены незафиксированные изменения.",
"workspace.status.clean": "Неслитые изменения не обнаружены.",
"workspace.status.dirty": "Обнаружены неслитые изменения в этом рабочем пространстве.",
"workspace.delete.title": "Удалить рабочее пространство",
"workspace.delete.confirm": 'Удалить рабочее пространство "{{name}}"?',
"workspace.delete.button": "Удалить рабочее пространство",
"workspace.reset.title": "Сбросить рабочее пространство",
"workspace.reset.confirm": 'Сбросить рабочее пространство "{{name}}"?',
"workspace.reset.button": "Сбросить рабочее пространство",
"workspace.reset.archived.none": "Активные сессии не будут архивированы.",
"workspace.reset.archived.none": "Никакие активные сессии не будут архивированы.",
"workspace.reset.archived.one": "1 сессия будет архивирована.",
"workspace.reset.archived.many": "{{count}} сессий будет архивировано.",
"workspace.reset.note": "Это сбросит рабочее пространство до соответствия ветке по умолчанию.",
"workspace.reset.note": "Рабочее пространство будет сброшено в соответствие с веткой по умолчанию.",
"common.open": "Открыть",
"dialog.releaseNotes.action.getStarted": "Начать",
"dialog.releaseNotes.action.next": "Далее",
@@ -867,79 +836,4 @@ export const dict = {
"common.time.daysAgo.short": "{{count}} д назад",
"settings.providers.connected.environmentDescription": "Подключено из ваших переменных окружения",
"settings.providers.custom.description": "Добавить провайдера, совместимого с OpenAI, по базовому URL.",
"app.server.unreachable": "Не удалось связаться с {{server}}",
"app.server.retrying": "Автоматическая повторная попытка...",
"app.server.otherServers": "Другие серверы",
"dialog.server.add.usernamePlaceholder": "имя пользователя",
"dialog.server.add.passwordPlaceholder": "пароль",
"server.row.noUsername": "нет имени пользователя",
"session.review.noVcs.createGit.title": "Создать репозиторий Git",
"session.review.noVcs.createGit.description": "Отслеживайте, просматривайте и отменяйте изменения в этом проекте",
"session.review.noVcs.createGit.actionLoading": "Создание репозитория Git...",
"session.review.noVcs.createGit.action": "Создать репозиторий Git",
"session.todo.progress": "Выполнено {{done}} из {{total}} задач",
"session.question.progress": "{{current}} из {{total}} вопросов",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Проводник",
"session.header.open.fileManager": "Файловый менеджер",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Терминал",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "Диагностика производительности разработки",
"debugBar.na": "н/д",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Последний завершенный переход маршрута, затрагивающий страницу сеанса, измеренный от запуска маршрутизатора до первой отрисовки после стабилизации.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Скользящая частота кадров в секунду за последние 5 секунд.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Худшее время кадра за последние 5 секунд.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Кадры более 32 мс за последние 5 секунд.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Заблокированное время и количество длинных задач за последние 5 секунд. Макс. задача: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Худшая наблюдаемая задержка ввода за последние 5 секунд.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Приблизительная продолжительность взаимодействия за последние 5 секунд. Это похоже на INP, а не официальный Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Кумулятивный сдвиг макета за текущее время жизни приложения.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Используемая куча JS по сравнению с лимитом кучи. Только Chromium.",
"debugBar.mem.tip": "Используемая куча JS по сравнению с лимитом кучи. {{used}} из {{limit}}.",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Пробел",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "неизвестно",
"error.page.circular": "[Циклично]",
"error.globalSDK.noServerAvailable": "Нет доступного сервера",
"error.globalSDK.serverNotAvailable": "Сервер недоступен",
"error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта",
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
"error.childStore.storeCreateFailed": "Не удалось создать хранилище",
"terminal.connectionLost.abnormalClose": "WebSocket закрыт аварийно: {{code}}",
}

View File

@@ -113,7 +113,6 @@ export const dict = {
"dialog.model.empty": "ไม่พบผลลัพธ์โมเดล",
"dialog.model.manage": "จัดการโมเดล",
"dialog.model.manage.description": "ปรับแต่งโมเดลที่จะปรากฏในตัวเลือกโมเดล",
"dialog.model.manage.provider.toggle": "สลับโมเดลทั้งหมดของ {{provider}}",
"dialog.model.unpaid.freeModels.title": "โมเดลฟรีที่จัดหาให้โดย OpenCode",
"dialog.model.unpaid.addMore.title": "เพิ่มโมเดลเพิ่มเติมจากผู้ให้บริการยอดนิยม",
@@ -263,7 +262,7 @@ export const dict = {
"prompt.popover.emptyResults": "ไม่พบผลลัพธ์ที่ตรงกัน",
"prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน",
"prompt.dropzone.label": "ลากรูปภาพ, PDF หรือไฟล์ข้อความมาวางที่นี่",
"prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่",
"prompt.dropzone.file.label": "วางเพื่อ @กล่าวถึงไฟล์",
"prompt.slash.badge.custom": "กำหนดเอง",
"prompt.slash.badge.skill": "ทักษะ",
@@ -277,8 +276,8 @@ export const dict = {
"prompt.action.send": "ส่ง",
"prompt.action.stop": "หยุด",
"prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
"prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น",
"prompt.toast.pasteUnsupported.title": "การวางไม่รองรับ",
"prompt.toast.pasteUnsupported.description": "สามารถวางรูปภาพหรือ PDF เท่านั้น",
"prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
"prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",
"prompt.toast.worktreeCreateFailed.title": "ไม่สามารถสร้าง worktree",
@@ -315,11 +314,6 @@ export const dict = {
"dialog.server.add.error": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์",
"dialog.server.add.checking": "กำลังตรวจสอบ...",
"dialog.server.add.button": "เพิ่มเซิร์ฟเวอร์",
"dialog.server.add.name": "ชื่อเซิร์ฟเวอร์ (ไม่บังคับ)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "ชื่อผู้ใช้ (ไม่บังคับ)",
"dialog.server.add.password": "รหัสผ่าน (ไม่บังคับ)",
"dialog.server.edit.title": "แก้ไขเซิร์ฟเวอร์",
"dialog.server.default.title": "เซิร์ฟเวอร์เริ่มต้น",
"dialog.server.default.description":
"เชื่อมต่อกับเซิร์ฟเวอร์นี้เมื่อเปิดแอปแทนการเริ่มเซิร์ฟเวอร์ในเครื่อง ต้องรีสตาร์ท",
@@ -397,7 +391,6 @@ export const dict = {
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "ภาษา",
"toast.language.description": "สลับไปที่ {{language}}",
@@ -501,12 +494,9 @@ export const dict = {
"session.review.change.other": "การเปลี่ยนแปลง",
"session.review.loadingChanges": "กำลังโหลดการเปลี่ยนแปลง...",
"session.review.empty": "ยังไม่มีการเปลี่ยนแปลงในเซสชันนี้",
"session.review.noVcs": "ไม่ตรวจพบระบบควบคุมเวอร์ชัน Git การเปลี่ยนแปลงจะไม่แสดง",
"session.review.noSnapshot": "การติดตามสแนปชอตถูกปิดใช้งานในการกำหนดค่า ดังนั้นการเปลี่ยนแปลงเซสชันจึงไม่พร้อมใช้งาน",
"session.review.noChanges": "ไม่มีการเปลี่ยนแปลง",
"session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
"session.files.empty": "ไม่มีไฟล์",
"session.files.all": "ไฟล์ทั้งหมด",
"session.files.binaryContent": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)",
@@ -520,17 +510,6 @@ export const dict = {
"session.todo.title": "สิ่งที่ต้องทำ",
"session.todo.collapse": "ย่อ",
"session.todo.expand": "ขยาย",
"session.followupDock.summary.one": "{{count}} ข้อความในคิว",
"session.followupDock.summary.other": "{{count}} ข้อความในคิว",
"session.followupDock.sendNow": "ส่งทันที",
"session.followupDock.edit": "แก้ไข",
"session.followupDock.collapse": "ย่อข้อความในคิว",
"session.followupDock.expand": "ขยายข้อความในคิว",
"session.revertDock.summary.one": "{{count}} ข้อความที่ถูกย้อนกลับ",
"session.revertDock.summary.other": "{{count}} ข้อความที่ถูกย้อนกลับ",
"session.revertDock.collapse": "ย่อข้อความที่ถูกย้อนกลับ",
"session.revertDock.expand": "ขยายข้อความที่ถูกย้อนกลับ",
"session.revertDock.restore": "กู้คืนข้อความ",
"session.new.title": "สร้างอะไรก็ได้",
"session.new.worktree.main": "สาขาหลัก",
@@ -625,18 +604,11 @@ export const dict = {
"settings.general.row.language.description": "เปลี่ยนภาษาที่แสดงสำหรับ OpenCode",
"settings.general.row.appearance.title": "รูปลักษณ์",
"settings.general.row.appearance.description": "ปรับแต่งวิธีการที่ OpenCode มีลักษณะบนอุปกรณ์ของคุณ",
"settings.general.row.colorScheme.title": "โทนสี",
"settings.general.row.colorScheme.description": "เลือกว่าจะให้ OpenCode ใช้ธีมตามระบบ สว่าง หรือมืด",
"settings.general.row.theme.title": "ธีม",
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
"settings.general.row.font.title": "ฟอนต์",
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
"settings.general.row.followup.description": "เลือกว่าจะให้พร้อมท์ติดตามผลทำงานทันทีหรือรอในคิว",
"settings.general.row.followup.option.queue": "คิว",
"settings.general.row.followup.option.steer": "นำทาง",
"settings.general.row.reasoningSummaries.title": "แสดงสรุปการใช้เหตุผล",
"settings.general.row.reasoningSummaries.description": "แสดงสรุปการใช้เหตุผลของโมเดลในไทม์ไลน์",
"settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
"settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
@@ -854,79 +826,4 @@ export const dict = {
"common.time.daysAgo.short": "{{count}} วันที่แล้ว",
"settings.providers.connected.environmentDescription": "เชื่อมต่อจากตัวแปรสภาพแวดล้อมของคุณ",
"settings.providers.custom.description": "เพิ่มผู้ให้บริการที่รองรับ OpenAI ด้วย URL หลัก",
"app.server.unreachable": "ไม่สามารถติดต่อ {{server}}",
"app.server.retrying": "กำลังลองใหม่โดยอัตโนมัติ...",
"app.server.otherServers": "เซิร์ฟเวอร์อื่น ๆ",
"dialog.server.add.usernamePlaceholder": "ชื่อผู้ใช้",
"dialog.server.add.passwordPlaceholder": "รหัสผ่าน",
"server.row.noUsername": "ไม่มีชื่อผู้ใช้",
"session.review.noVcs.createGit.title": "สร้าง Git repository",
"session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้",
"session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git repository...",
"session.review.noVcs.createGit.action": "สร้าง Git repository",
"session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ",
"session.question.progress": "{{current}} จาก {{total}} คำถาม",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "File Explorer",
"session.header.open.fileManager": "File Manager",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Terminal",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"debugBar.ariaLabel": "การวินิจฉัยประสิทธิภาพการพัฒนา",
"debugBar.na": "n/a",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"การเปลี่ยนเส้นทางที่เสร็จสมบูรณ์ล่าสุดที่สัมผัสหน้าเซสชัน วัดจากจุดเริ่มต้นเราเตอร์จนถึงการวาดครั้งแรกหลังจากที่นิ่ง",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "เฟรมต่อวินาทีแบบต่อเนื่องในช่วง 5 วินาทีที่ผ่านมา",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "เวลาเฟรมที่แย่ที่สุดในช่วง 5 วินาทีที่ผ่านมา",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "เฟรมที่เกิน 32ms ในช่วง 5 วินาทีที่ผ่านมา",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "เวลาที่ถูกบล็อกและจำนวนงานยาวในช่วง 5 วินาทีที่ผ่านมา งานสูงสุด: {{max}}",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "ความล่าช้าในการป้อนข้อมูลที่แย่ที่สุดที่สังเกตได้ในช่วง 5 วินาทีที่ผ่านมา",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"ระยะเวลาการโต้ตอบโดยประมาณในช่วง 5 วินาทีที่ผ่านมา นี่เป็นเหมือน INP ไม่ใช่ Web Vitals INP อย่างเป็นทางการ",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "การเลื่อนเลย์เอาต์สะสมสำหรับอายุการใช้งานของแอปปัจจุบัน",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "JS heap ที่ใช้เทียบกับขีดจำกัด heap เฉพาะ Chromium",
"debugBar.mem.tip": "JS heap ที่ใช้เทียบกับขีดจำกัด heap {{used}} จาก {{limit}}",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Space",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "ไม่ทราบ",
"error.page.circular": "[วงกลม]",
"error.globalSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
"error.globalSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
"error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร",
"error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร",
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
"error.childStore.storeCreateFailed": "ไม่สามารถสร้างที่เก็บ",
"terminal.connectionLost.abnormalClose": "WebSocket ปิดอย่างผิดปกติ: {{code}}",
}

Some files were not shown because too many files have changed in this diff Show More