Compare commits

..

108 Commits

Author SHA1 Message Date
opencode-agent[bot]
c1c713c1b8 chore: update nix node_modules hashes 2026-03-13 01:47:32 +00:00
opencode-agent[bot]
2cb557fe40 Apply PR #17254: remove sighup exit 2026-03-13 01:43:42 +00:00
opencode-agent[bot]
1a92e494a7 Apply PR #17227: refactor(provider): effectify ProviderAuthService 2026-03-13 01:43:41 +00:00
opencode-agent[bot]
0bccf000e8 Apply PR #16957: fix(cli): scope active org labels to the active account 2026-03-13 01:43:40 +00:00
opencode-agent[bot]
374762cb9c Apply PR #16918: opencode 2-0 2026-03-13 01:43:39 +00:00
opencode-agent[bot]
678c3089c0 Apply PR #15697: tweak(ui): make questions popup collapsible 2026-03-13 01:42:00 +00:00
opencode-agent[bot]
f6601d732b Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-03-13 01:42:00 +00:00
opencode-agent[bot]
3f42be87c6 Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-03-13 01:42:00 +00:00
opencode-agent[bot]
89aa74a5ae Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-03-13 01:41:16 +00:00
opencode-agent[bot]
d82c24cdc2 Apply PR #12022: feat: update tui model dialog to utilize model family to reduce noise in list 2026-03-13 01:41:15 +00:00
Kit Langton
eb3ab56bdd refactor(provider): single InstanceState.get per method 2026-03-12 21:38:18 -04:00
Brendan Allan
84df96eaef desktop: multi-window support in electron (#17155) 2026-03-13 09:18:27 +08:00
Kit Langton
c39e5fe9f9 Merge branch 'effect-auth-foundation' into effect-auth-provider-flow 2026-03-12 21:12:15 -04:00
Kit Langton
87a6c54226 Merge branch 'dev' into effect-auth-foundation 2026-03-12 21:12:08 -04:00
Kit Langton
2c1984e222 test(state): rewrite State tests to go through Instance.state 2026-03-12 21:10:30 -04:00
Kit Langton
106b7a9d15 fix(provider): use ProviderID type for pending OAuth map key 2026-03-12 20:57:19 -04:00
Kit Langton
d9dd33aeeb feat(cli): add console account subcommands (#17265) 2026-03-13 00:56:40 +00:00
Kit Langton
0a281c7390 refactor(auth): effectify AuthService (#17212) 2026-03-12 20:43:24 -04:00
Aiden Cline
3016efba47 tweak: rm openrouter warning (#17259) 2026-03-12 19:42:31 -05:00
Kit Langton
c45fbb3d45 refactor(state): namespace InstanceState, use Map for pending
- Convert InstanceState to namespace export pattern
- Change pending OAuth state from Record to Map for type-safe lookups
2026-03-12 20:31:15 -04:00
Luke Parker
3998df8112 fix(app): increase CI e2e workers (#17263) 2026-03-13 10:15:34 +10:00
Kit Langton
7066e2a25e reorder provider list in providers login (#17262) 2026-03-13 00:09:30 +00:00
Adam
c173988aaa feat(app): interruption state 2026-03-12 19:07:23 -05:00
Luke Parker
268855dc5a fix(ci): keep test runs on dev (#17260) 2026-03-13 09:54:34 +10:00
opencode
bfb736e94a release: v1.2.25 2026-03-12 23:34:11 +00:00
Sebastian Herrlinger
46436f8ce0 remove sighup exit 2026-03-13 00:22:54 +01:00
Frank
df8464f89c zen: handle cache key 2026-03-12 19:10:58 -04:00
Adam
3ea387f364 fix(app): sidebar re-rendering too often 2026-03-12 16:27:15 -05:00
Adam
9d3c42c8c4 fix(app): task error state 2026-03-12 16:25:49 -05:00
Adam
f2cad046e6 fix(app): message loading 2026-03-12 16:25:48 -05:00
Aiden Cline
d722026a8d fix: if server password exists, use basic auth for plugin client by default (#17213) 2026-03-12 15:41:46 -05:00
Kit Langton
395a9580bd refactor(state): replace ScopedState with InstanceState
Replace the generic ScopedState (keyed by caller-provided root) with
InstanceState that hardcodes Instance.directory and integrates with
the instance dispose/reload lifecycle via a global task registry.

- Parallelize State + InstanceState disposal in reload/dispose
- Use Effect's Record.map and Struct.pick in auth-service
- Flatten nested Effect.gen in OAuth callback flow
- Add docstrings to ProviderAuthService interface
- Add State and InstanceState tests
2026-03-12 16:33:50 -04:00
Kit Langton
fedaeea9da refactor(state): add effectful ScopedState
Add a real effect-style scoped state data type built on ScopedCache and cover its caching, invalidation, concurrency, and scope-finalization semantics with focused tests. Move ProviderAuthService onto the new abstraction so the service no longer depends on Instance.state directly.
2026-03-12 16:33:50 -04:00
Adam
42a5af6c8f feat(app): follow-up behavior (#17233) 2026-03-12 20:17:36 +00:00
Adam
f0542fae7a fix(app): optimistic revert/restore 2026-03-12 15:04:27 -05:00
Adam
02c75821a8 feat(app): AMOLED theme 2026-03-12 14:11:22 -05:00
Kit Langton
3cfdb07fb8 refactor(provider): extract ProviderAuthService
Move ProviderAuth state and persistence logic into a real Effect service so the provider auth module follows the same facade-over-service migration pattern as account and auth. Keep the existing zod-facing ProviderAuth API as a thin promise bridge over the new service.
2026-03-12 14:51:02 -04:00
Kit Langton
f96f235dcf refactor(provider): use AuthService in auth flows
Point ProviderAuth persistence at AuthService instead of going back through the legacy Auth facade. Add a focused test that exercises the provider auth API path and confirms credentials still persist correctly.
2026-03-12 14:43:09 -04:00
Kit Langton
1739817ee7 style(auth): remove service docstrings
Drop the temporary auth service method comments now that the key normalization behavior has been reviewed.
2026-03-12 14:38:44 -04:00
Kit Langton
11e2c85336 chore: keep effect migration plan local
Remove the draft migration plan from the auth foundation branch and keep it excluded locally instead of shipping it in the PR.
2026-03-12 14:34:29 -04:00
Adam
3ba9ab2c0a fix(app): not loading message nav 2026-03-12 13:33:17 -05:00
David Hill
184732fc20 fix(app): titlebar cleanup (#17206) 2026-03-12 18:26:50 +00:00
Kit Langton
201e80956a refactor(auth): clarify auth entry filtering
Use Effect Record.filterMap to keep the existing permissive auth-file semantics while making the decode path easier to read. Add service method docs that explain key normalization and why old trailing-slash variants are removed during writes.
2026-03-12 14:23:27 -04:00
Frank
b66222baf7 zen: fix nemotron issue 2026-03-12 14:17:36 -04:00
Kit Langton
7f12976ea0 refactor(auth): use Effect Schema internally
Model auth entries with Effect Schema inside AuthService and use Schema decoding when reading persisted auth data. Keep the Auth facade on Zod at the boundary so existing validators and callers stay stable during the migration.
2026-03-12 13:25:52 -04:00
Kit Langton
f7259617e5 refactor(auth): extract AuthService
Move auth file I/O and key normalization into an Effect service so auth can migrate like account while the existing Auth facade stays stable for callers. Document the broader Effect rollout and instance-state migration strategy to guide follow-on extractions.
2026-03-12 13:05:50 -04:00
Luke Parker
7910ce5d36 fix: guard Npm.which() against infinite loop when .bin is empty (#16961) 2026-03-11 09:34:58 -04:00
Kit Langton
0f8777b86f chore(cli): drop org active-state regression test 2026-03-10 22:51:17 -04:00
Kit Langton
4fec184c92 fix(cli): scope active org labels to the active account 2026-03-10 22:45:44 -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
MakonnenMak
e993acec31 Merge remote-tracking branch 'upstream/dev' into fix/clock-skew-prompt-loop-exit
# Conflicts:
#	packages/ui/src/components/session-turn.tsx
2026-03-02 13:30:01 -05:00
David Hill
611e616010 tui: add right margin to question progress indicator so it doesn't touch the container edge 2026-03-02 14:27:43 +00:00
David Hill
b286c0ae3f tweak(ui): restore questions progress indicator 2026-03-02 12:08:37 +00:00
David Hill
81a61f8dbd tweak(ui): improve collapse area 2026-03-02 11:14:01 +00:00
David Hill
752e449e38 tweak(ui): improve collpase area 2026-03-02 11:11:52 +00:00
David Hill
5d419a0211 tweak(ui): expand question dock toggle area 2026-02-27 21:30:49 +00:00
David Hill
8b168981aa tweak(ui): active state on type your own answer 2026-02-27 18:50:50 +00:00
David Hill
724dd665ec tweak(ui): collapse questions 2026-02-27 18:47:53 +00:00
MakonnenMak
fc258ea74f fix: remove as any type cast in processor exit logic 2026-02-20 13:20:10 -05:00
Makonnen
abd9e195ac fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering
When the client clock is ahead of the server, user message IDs (generated
client-side) sort after assistant message IDs (generated server-side).
This broke the prompt loop exit check and the UI message pairing logic.

- Extract shouldExitLoop() into a pure function that uses parentID matching
  instead of relying on ID ordering
- Extract findAssistantMessages() with forward+backward scan to handle
  messages sorted out of expected order due to clock skew
- Remove debug console.log statements added during investigation
- Add tests for both extracted functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:20:10 -05:00
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06:00
Dax
e31f00ad22 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-16 21:50:34 -05:00
LukeParkerDev
a90e8de050 add missing return 2026-02-11 13:24:17 +10:00
Aiden Cline
eabf770053 Merge branch 'dev' into utilize-family-in-dialog 2026-02-10 14:43:15 -06:00
Dax
86d7bdc542 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:55:01 -05:00
Dax
d3ab78bba0 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:04:40 -05:00
Dax Raad
a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad
bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad
ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad
ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad
f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad
405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad
878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Aiden Cline
bb4d978684 feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-03 15:48:40 -06:00
180 changed files with 5833 additions and 2429 deletions

View File

@@ -8,7 +8,9 @@ on:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# 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)) }}
cancel-in-progress: true
permissions:

View File

@@ -1,4 +1,6 @@
plans/
bun.lock
node_modules
plans
package.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.

1604
bun.lock

File diff suppressed because it is too large Load Diff

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-TnrYykX8Mf/Ugtkix6V",
"aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
"aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V",
"x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.24",
"version": "1.2.25",
"description": "",
"type": "module",
"exports": {

View File

@@ -6,6 +6,7 @@ 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",
@@ -17,6 +18,7 @@ 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

@@ -159,7 +159,7 @@ const effectMinDuration =
<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) {
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
@@ -168,21 +168,23 @@ function ConnectionGate(props: ParentProps) {
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
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,
),
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 (
@@ -261,10 +263,11 @@ export function AppInterface(props: {
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ConnectionGate>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic

View File

@@ -48,7 +48,7 @@ import {
type PromptHistoryStoredEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit"
import { createPromptSubmit, type FollowupDraft } 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"
@@ -61,6 +61,11 @@ 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
}
@@ -947,6 +952,45 @@ 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,
@@ -1001,6 +1045,9 @@ 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,
})

View File

@@ -9,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 ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { Identifier } from "@/utils/id"
@@ -25,6 +25,145 @@ 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[]>
@@ -41,6 +180,9 @@ 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
}
@@ -82,6 +224,8 @@ 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()
@@ -116,6 +260,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const clearContext = () => {
for (const item of prompt.context.items()) {
prompt.context.remove(item.key)
}
}
const handleSubmit = async (event: Event) => {
event.preventDefault()
@@ -215,14 +365,22 @@ 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()
@@ -243,6 +401,15 @@ 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
@@ -295,48 +462,19 @@ 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 { 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 = () =>
const removeOptimisticMessage = () => {
sync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
removeCommentItems(commentItems)
clearInput()
addOptimisticMessage()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
@@ -393,20 +531,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return true
}
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) => {
void sendFollowupDraft({
client,
sync,
globalSync,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree,
}).catch((err) => {
pending.delete(session.id)
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })

View File

@@ -65,22 +65,26 @@ export function ServerRow(props: ServerRowProps) {
return (
<Tooltip
class="flex-1"
class="flex-1 min-w-0"
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">
<div class="flex flex-row items-center gap-2">
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
<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`}>
{name()}
</span>
<Show
when={badge()}
fallback={
<Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
<span
ref={versionRef}
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
>
v{props.status?.version}
</span>
</Show>

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
function user(id: string): Message {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: 1 },
} as unknown as Message
}
function assistant(id: string, parentID: string): Message {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
time: { created: 1 },
} as unknown as Message
}
describe("findAssistantMessages", () => {
test("normal ordering: assistant after user in array → found via forward scan", () => {
const messages = [user("u1"), assistant("a1", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("clock skew: assistant before user in array → found via backward scan", () => {
// When client clock is ahead, user ID sorts after assistant ID,
// so assistant appears earlier in the ID-sorted message array
const messages = [assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 1, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("no assistant messages → returns empty array", () => {
const messages = [user("u1"), user("u2")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(0)
})
test("multiple assistant messages with matching parentID → all found", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("a1")
expect(result[1].id).toBe("a2")
})
test("does not return assistant messages with different parentID", () => {
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops forward scan at next user message", () => {
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
const result = findAssistantMessages(messages, 0, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("stops backward scan at previous user message", () => {
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
const result = findAssistantMessages(messages, 3, "u1")
expect(result).toHaveLength(1)
expect(result[0].id).toBe("a1")
})
test("invalid index returns empty array", () => {
const messages = [user("u1")]
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
})
})

View File

@@ -4,9 +4,7 @@ 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"
@@ -14,12 +12,10 @@ 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"
@@ -112,12 +108,6 @@ const LINUX_APPS = [
},
] 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"
@@ -136,98 +126,10 @@ 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 command = useCommand()
const server = useServer()
const sync = useSync()
const platform = usePlatform()
const language = useLanguage()
const terminal = useTerminal()
@@ -245,10 +147,6 @@ 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 os = createMemo(() => detectOS(platform))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
@@ -356,14 +254,6 @@ 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"))
@@ -391,7 +281,9 @@ export function SessionHeader() {
<Show when={hotkey()}>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind()}
</Keybind>
)}
</Show>
</Button>
@@ -402,7 +294,6 @@ 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
@@ -427,7 +318,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-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
class="rounded-none h-full py-0 pr-1.5 pl-px gap-1.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
@@ -435,17 +326,13 @@ 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">
<Show
when={opening()}
fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
>
<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} />}>
<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"
@@ -457,17 +344,20 @@ export function SessionHeader() {
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
class="rounded-none h-full w-[20px] 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>
<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.Group>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{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
@@ -484,8 +374,8 @@ export function SessionHeader() {
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
@@ -518,113 +408,10 @@ 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")}
@@ -637,23 +424,7 @@ export function SessionHeader() {
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<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>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
@@ -670,23 +441,7 @@ export function SessionHeader() {
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<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>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>

View File

@@ -113,6 +113,11 @@ 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,
@@ -170,10 +175,8 @@ export const SettingsGeneral: Component = () => {
triggerVariant: "settings" as const,
})
const AppearanceSection = () => (
const GeneralSection = () => (
<div class="flex flex-col gap-1">
<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")}
@@ -193,8 +196,70 @@ export const SettingsGeneral: Component = () => {
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.appearance.title")}
description={language.t("settings.general.row.appearance.description")}
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>
</div>
</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>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<Select
data-action="settings-color-scheme"
@@ -211,6 +276,7 @@ export const SettingsGeneral: Component = () => {
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "220px" }}
/>
</SettingsRow>
@@ -271,50 +337,6 @@ export const SettingsGeneral: Component = () => {
</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>
)
const NotificationsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
@@ -465,9 +487,9 @@ export const SettingsGeneral: Component = () => {
</div>
<div class="flex flex-col gap-8 w-full">
<AppearanceSection />
<GeneralSection />
<FeedSection />
<AppearanceSection />
<NotificationsSection />
@@ -551,12 +573,12 @@ interface SettingsRowProps {
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<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">
<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">
<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 class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
</div>
)
}

View File

@@ -169,6 +169,7 @@ export function StatusPopover() {
const language = useLanguage()
const navigate = useNavigate()
const [shown, setShown] = createSignal(false)
const servers = createMemo(() => {
const current = server.current
const list = server.list
@@ -199,18 +200,23 @@ export function StatusPopover() {
return (
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs={Button}
triggerProps={{
variant: "ghost",
class: "titlebar-icon w-6 h-6 p-0 box-border",
class: "titlebar-icon w-8 h-6 p-0 box-border",
"aria-label": language.t("status.popover.trigger"),
style: { scale: 1 },
}}
trigger={
<div class="flex size-4 items-center justify-center">
<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
classList={{
"size-1.5 rounded-full": true,
"absolute -top-px -right-px 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

@@ -58,6 +58,12 @@ 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()
@@ -206,19 +212,7 @@ export function Titlebar() {
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<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>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
@@ -231,13 +225,14 @@ export function Titlebar() {
>
<Button
variant="ghost"
icon="new-session"
icon={creating() ? "new-session-active" : "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>
@@ -266,6 +261,9 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">

View File

@@ -22,6 +22,7 @@ export interface Settings {
general: {
autoSave: boolean
releaseNotes: boolean
followup: "queue" | "steer"
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
@@ -45,6 +46,7 @@ const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
followup: "steer",
showReasoningSummaries: false,
shellToolPartsExpanded: true,
editToolPartsExpanded: false,
@@ -126,6 +128,10 @@ 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

@@ -233,8 +233,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
})
.finally(() => {
if (!tracked(input.directory, input.sessionID)) return
setMeta("loading", key, false)
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}

View File

@@ -1,6 +1,5 @@
// @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"
@@ -132,7 +131,11 @@ if (root instanceof HTMLElement) {
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
<AppInterface
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
servers={[server]}
disableHealthCheck
/>
</AppBaseProviders>
</PlatformProvider>
),

View File

@@ -104,6 +104,7 @@ 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": "عرض المزيد من الموفرين",
@@ -288,6 +289,11 @@ 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":
"الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
@@ -358,6 +364,7 @@ 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": "تم تبديل السمة",
@@ -444,8 +451,11 @@ 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": "جارٍ تحميل الرسائل السابقة...",
@@ -456,6 +466,17 @@ 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}})",
@@ -538,10 +559,18 @@ 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 موسعة بشكل افتراضي في الشريط الزمني",

View File

@@ -104,6 +104,7 @@ 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",
@@ -288,6 +289,11 @@ 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.",
@@ -359,6 +365,7 @@ 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",
@@ -446,9 +453,13 @@ 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...",
@@ -459,6 +470,17 @@ 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}})",
@@ -544,10 +566,19 @@ 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",

View File

@@ -113,6 +113,7 @@ 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",
@@ -315,6 +316,11 @@ 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.",
@@ -393,6 +399,7 @@ 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}}",
@@ -498,10 +505,14 @@ 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",
@@ -514,6 +525,17 @@ 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",
@@ -609,10 +631,18 @@ 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":

View File

@@ -113,6 +113,7 @@ 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",
@@ -313,6 +314,11 @@ 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.",
@@ -391,6 +397,7 @@ 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}}",
@@ -495,9 +502,13 @@ 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...",
@@ -509,6 +520,17 @@ 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",
@@ -604,10 +626,18 @@ 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",

View File

@@ -108,6 +108,7 @@ 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",
@@ -294,6 +295,11 @@ 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.",
@@ -366,6 +372,7 @@ 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",
@@ -454,9 +461,13 @@ 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...",
@@ -467,6 +478,17 @@ 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}})",
@@ -553,10 +575,21 @@ 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",

View File

@@ -530,6 +530,12 @@ export const dict = {
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"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",
@@ -638,10 +644,16 @@ 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,6 +113,7 @@ 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",
@@ -314,6 +315,11 @@ 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.",
@@ -393,6 +399,7 @@ 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}}",
@@ -499,10 +506,14 @@ 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",
@@ -515,6 +526,17 @@ 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",
@@ -612,11 +634,20 @@ 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",

View File

@@ -104,6 +104,7 @@ 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",
@@ -288,6 +289,11 @@ 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.",
@@ -360,6 +366,7 @@ 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é",
@@ -451,8 +458,12 @@ 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...",
@@ -463,6 +474,17 @@ 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}})",
@@ -550,10 +572,20 @@ 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",

View File

@@ -104,6 +104,7 @@ 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": "さらにプロバイダーを表示",
@@ -287,6 +288,11 @@ 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":
"ローカルサーバーを起動する代わりに、アプリ起動時にこのサーバーに接続します。再起動が必要です。",
@@ -358,6 +364,7 @@ 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": "テーマが切り替わりました",
@@ -444,9 +451,12 @@ 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": "以前のメッセージを読み込み中...",
@@ -457,6 +467,17 @@ 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}})",
@@ -542,10 +563,19 @@ 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 ツールパーツをデフォルトで展開して表示します",

View File

@@ -108,6 +108,7 @@ 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": "더 많은 공급자 보기",
@@ -291,6 +292,11 @@ 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":
"로컬 서버를 시작하는 대신 앱 실행 시 이 서버에 연결합니다. 다시 시작해야 합니다.",
@@ -361,6 +367,7 @@ 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": "테마 전환됨",
@@ -446,9 +453,12 @@ 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": "이전 메시지 로드 중...",
@@ -459,6 +469,17 @@ 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}})",
@@ -543,10 +564,18 @@ 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 도구 파트를 펼친 상태로 표시합니다",

View File

@@ -116,6 +116,7 @@ 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",
@@ -216,7 +217,7 @@ export const dict = {
"common.search.placeholder": "Søk",
"common.goBack": "Gå tilbake",
"common.goForward": "Navigate forward",
"common.goForward": "Gå frem",
"common.loading": "Laster",
"common.loading.ellipsis": "...",
"common.cancel": "Avbryt",
@@ -317,6 +318,11 @@ 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.",
@@ -394,6 +400,7 @@ 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}}",
@@ -499,10 +506,14 @@ 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",
@@ -515,6 +526,17 @@ 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",
@@ -612,11 +634,18 @@ 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",

View File

@@ -104,6 +104,7 @@ 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",
@@ -289,6 +290,11 @@ 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.",
@@ -359,6 +365,7 @@ 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",
@@ -445,9 +452,12 @@ 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...",
@@ -458,6 +468,17 @@ 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}})",
@@ -543,10 +564,19 @@ 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",

View File

@@ -113,6 +113,7 @@ 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": "Добавьте больше моделей от популярных провайдеров",
@@ -314,6 +315,11 @@ 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":
"Подключаться к этому серверу при запуске приложения вместо запуска локального сервера. Требуется перезапуск.",
@@ -393,6 +399,7 @@ 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}}",
@@ -499,9 +506,12 @@ 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": "Загрузка предыдущих сообщений...",
@@ -513,6 +523,17 @@ 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": "Основная ветка",
@@ -610,10 +631,19 @@ 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":
@@ -767,30 +797,31 @@ 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": "Список",
"settings.permissions.tool.list.title": "List",
"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": "Чтение списка задач",
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
"settings.permissions.tool.todoread.title": "Todo Read",
"settings.permissions.tool.todoread.description": "Чтение списка задач",
"settings.permissions.tool.todowrite.title": "Запись списка задач",
"settings.permissions.tool.todowrite.title": "Todo Write",
"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": "Поиск кода",
"settings.permissions.tool.codesearch.title": "Code Search",
"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": "Удалить сессию",
@@ -807,21 +838,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": "Далее",

View File

@@ -113,6 +113,7 @@ 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": "เพิ่มโมเดลเพิ่มเติมจากผู้ให้บริการยอดนิยม",
@@ -314,6 +315,11 @@ 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":
"เชื่อมต่อกับเซิร์ฟเวอร์นี้เมื่อเปิดแอปแทนการเริ่มเซิร์ฟเวอร์ในเครื่อง ต้องรีสตาร์ท",
@@ -391,6 +397,7 @@ 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}}",
@@ -494,9 +501,12 @@ 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": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)",
@@ -510,6 +520,17 @@ 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": "สาขาหลัก",
@@ -604,11 +625,18 @@ 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",

View File

@@ -320,6 +320,11 @@ export const dict = {
"dialog.server.add.error": "Sunucuya bağlanılamadı",
"dialog.server.add.checking": "Kontrol ediliyor...",
"dialog.server.add.button": "Sunucu ekle",
"dialog.server.add.name": "Sunucu adı (isteğe bağlı)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Kullanıcı adı (isteğe bağlı)",
"dialog.server.add.password": "Şifre (isteğe bağlı)",
"dialog.server.edit.title": "Sunucuyu düzenle",
"dialog.server.default.title": "Varsayılan sunucu",
"dialog.server.default.description":
"Uygulama başlatıldığında yerel sunucu başlatmak yerine bu sunucuya bağlan. Yeniden başlatma gerektirir.",
@@ -506,10 +511,13 @@ export const dict = {
"session.review.loadingChanges": "Değişiklikler yükleniyor...",
"session.review.empty": "Bu oturumda henüz değişiklik yok",
"session.review.noVcs": "Git VCS algılanamadı, oturum değişiklikleri tespit edilemeyecek",
"session.review.noSnapshot":
"Yapılandırmada anlık görüntü takibi devre dışı bırakıldı, bu nedenle oturum değişiklikleri kullanılamıyor",
"session.review.noChanges": "Değişiklik yok",
"session.files.selectToOpen": "Açmak için bir dosya seçin",
"session.files.all": "Tüm dosyalar",
"session.files.empty": "Dosya yok",
"session.files.binaryContent": "İkili dosya (içerik görüntülenemiyor)",
"session.messages.renderEarlier": "Önceki mesajları göster",
@@ -522,6 +530,17 @@ export const dict = {
"session.todo.title": "Görevler",
"session.todo.collapse": "Daralt",
"session.todo.expand": "Genişlet",
"session.followupDock.summary.one": "{{count}} sıradaki mesaj",
"session.followupDock.summary.other": "{{count}} sıradaki mesaj",
"session.followupDock.sendNow": "Şimdi gönder",
"session.followupDock.edit": "Düzenle",
"session.followupDock.collapse": "Sıradaki mesajları daralt",
"session.followupDock.expand": "Sıradaki mesajları genişlet",
"session.revertDock.summary.one": "{{count}} geri alınan mesaj",
"session.revertDock.summary.other": "{{count}} geri alınan mesaj",
"session.revertDock.collapse": "Geri alınan mesajları daralt",
"session.revertDock.expand": "Geri alınan mesajları genişlet",
"session.revertDock.restore": "Mesajı geri yükle",
"session.new.title": "İstediğini yap",
"session.new.worktree.main": "Ana dal",
@@ -618,10 +637,18 @@ export const dict = {
"settings.general.row.language.description": "OpenCode'un görünüm dilini değiştirin",
"settings.general.row.appearance.title": "Görünüm",
"settings.general.row.appearance.description": "OpenCode'un cihazınızdaki görünümünü özelleştirin",
"settings.general.row.colorScheme.title": "Renk şeması",
"settings.general.row.colorScheme.description":
"OpenCode'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin",
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
"settings.general.row.font.title": "Yazı Tipi",
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
"settings.general.row.followup.title": "Takip davranışı",
"settings.general.row.followup.description":
"Takip komutlarının hemen yönlendirilmesini mi yoksa sırada beklemesini mi istediğinizi seçin",
"settings.general.row.followup.option.queue": "Sıra",
"settings.general.row.followup.option.steer": "Yönlendir",
"settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
"settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
"settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",

View File

@@ -140,6 +140,7 @@ 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": "从热门提供商添加更多模型",
@@ -334,6 +335,11 @@ 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": "应用启动时连接此服务器,而不是启动本地服务器。需要重启。",
"dialog.server.default.none": "未选择服务器",
@@ -406,6 +412,7 @@ 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}}",
@@ -497,9 +504,12 @@ 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": "正在加载更早的消息...",
@@ -510,6 +520,17 @@ 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}}",
@@ -604,10 +625,18 @@ 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": "展开编辑工具部分",

View File

@@ -117,6 +117,7 @@ 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": "從熱門提供者新增更多模型",
@@ -314,6 +315,11 @@ 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": "應用程式啟動時連線此伺服器,而不是啟動本地伺服器。需要重新啟動。",
"dialog.server.default.none": "未選擇伺服器",
@@ -390,6 +396,7 @@ 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}}",
@@ -493,8 +500,11 @@ 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": "正在載入更早的訊息...",
@@ -506,6 +516,17 @@ 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": "主分支",
@@ -585,8 +606,8 @@ export const dict = {
"settings.tab.general": "一般",
"settings.tab.shortcuts": "快速鍵",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.desktop.wsl.title": "WSL 整合",
"settings.desktop.wsl.description": "在 Windows 上的 WSL 中執行 OpenCode 伺服器。",
"settings.general.section.appearance": "外觀",
"settings.general.section.notifications": "系統通知",
@@ -599,10 +620,18 @@ 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 工具區塊",

View File

@@ -2203,8 +2203,8 @@ export default function Layout(props: ParentProps) {
mobile ? (
<SidebarPanel project={currentProject()} mobile />
) : (
<Show when={currentProject()} keyed>
{(project) => <SidebarPanel project={project} merged />}
<Show when={currentProject()}>
<SidebarPanel project={currentProject()} merged />
</Show>
)
}
@@ -2332,8 +2332,8 @@ export default function Layout(props: ParentProps) {
arm()
}}
>
<Show when={peek()} keyed>
{(project) => <SidebarPanel project={project} merged={false} />}
<Show when={peek()}>
<SidebarPanel project={peek()} merged={false} />
</Show>
</div>

View File

@@ -10,7 +10,6 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@@ -228,13 +227,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const hoverMessages = createMemo(() =>
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
)
const hoverReady = createMemo(() => {
if (sessionStore.message[props.session.id] === undefined) return false
if (props.session.id === params.id) return true
const info = getSessionPrefetch(props.session.directory, props.session.id)
if (!info) return false
return Date.now() - info.at < SESSION_PREFETCH_TTL
})
const hoverReady = createMemo(() => hoverMessages() !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)

View File

@@ -1,6 +1,7 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import {
batch,
onCleanup,
Show,
Match,
@@ -34,8 +35,10 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
@@ -46,15 +49,18 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = []
const emptyFollowups: (FollowupDraft & { id: string })[] = []
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
loaded: () => number
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
@@ -152,23 +158,39 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
let loaded = input.loaded()
if (start > 0) setTurnStart(0)
if (!input.historyMore() || input.historyLoading()) return
await input.loadMore(id)
if (input.sessionID() !== id) return
let afterVisible = beforeVisible
let added = 0
const afterVisible = input.visibleUserMessages().length
const growth = afterVisible - beforeVisible
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
afterVisible = input.visibleUserMessages().length
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
if (afterVisible > beforeVisible) break
if (raw <= 0) break
if (!input.historyMore()) break
}
if (added <= 0) return
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
const growth = afterVisible - beforeVisible
if (growth <= 0) return
if (turnStart() !== 0) return
const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
const nextStart = Math.max(0, afterVisible - target)
preserveScroll(() => setTurnStart(nextStart))
const target = Math.min(afterVisible, beforeVisible + turnBatch)
setTurnStart(Math.max(0, afterVisible - target))
}
/** Scroll/prefetch path: fetch older history from server. */
@@ -187,19 +209,35 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
let loaded = input.loaded()
let added = 0
let growth = 0
await input.loadMore(id)
if (input.sessionID() !== id) return
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
growth = input.visibleUserMessages().length - beforeVisible
if (growth > 0) break
if (raw <= 0) break
if (opts?.prefetch) break
if (!input.historyMore()) break
}
const afterVisible = input.visibleUserMessages().length
const growth = afterVisible - beforeVisible
if (opts?.prefetch) {
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (growth > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (added > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", 0)
}
if (added <= 0) return
if (growth <= 0) return
if (turnStart() !== start) return
@@ -269,6 +307,7 @@ export default function Page() {
const language = useLanguage()
const navigate = useNavigate()
const sdk = useSDK()
const settings = useSettings()
const prompt = usePrompt()
const comments = useComments()
const terminal = useTerminal()
@@ -291,6 +330,7 @@ export default function Page() {
git: false,
pendingMessage: undefined as string | undefined,
restoring: undefined as string | undefined,
reverting: false,
reviewSnap: false,
scrollGesture: 0,
scroll: {
@@ -464,6 +504,17 @@ export default function Page() {
deferRender: false,
})
const [followup, setFollowup] = createStore({
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
sending: {} as Record<string, string | undefined>,
failed: {} as Record<string, string | undefined>,
paused: {} as Record<string, boolean | undefined>,
edit: {} as Record<
string,
{ id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] } | undefined
>,
})
createComputed((prev) => {
const key = sessionKey()
if (key !== prev) {
@@ -1143,6 +1194,7 @@ export default function Page() {
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
let fillFrame: number | undefined
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
@@ -1190,10 +1242,14 @@ export default function Page() {
),
)
let fill = () => {}
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
if (el) scheduleScrollState(el)
if (!el) return
scheduleScrollState(el)
fill()
}
const markUserScroll = () => {
@@ -1205,12 +1261,14 @@ export default function Page() {
() => {
const el = scroller
if (el) scheduleScrollState(el)
fill()
},
)
const historyWindow = createSessionHistoryWindow({
sessionID: () => params.id,
messagesReady,
loaded: () => messages().length,
visibleUserMessages,
historyMore,
historyLoading,
@@ -1219,6 +1277,45 @@ export default function Page() {
scroller: () => scroller,
})
fill = () => {
if (fillFrame !== undefined) return
fillFrame = requestAnimationFrame(() => {
fillFrame = undefined
if (!params.id || !messagesReady()) return
if (autoScroll.userScrolled() || historyLoading()) return
const el = scroller
if (!el) return
if (el.scrollHeight > el.clientHeight + 1) return
if (historyWindow.turnStart() <= 0 && !historyMore()) return
void historyWindow.loadAndReveal()
})
}
createEffect(
on(
() =>
[
params.id,
messagesReady(),
historyWindow.turnStart(),
historyMore(),
historyLoading(),
autoScroll.userScrolled(),
visibleUserMessages().length,
] as const,
([id, ready, start, more, loading, scrolled]) => {
if (!id || !ready || loading || scrolled) return
if (start <= 0 && !more) return
fill()
},
{ defer: true },
),
)
const draft = (id: string) =>
extractPromptFromParts(sync.data.part[id] ?? [], {
directory: sdk.directory,
@@ -1243,13 +1340,136 @@ export default function Page() {
})
}
const merge = (next: NonNullable<ReturnType<typeof info>>) =>
sync.set("session", (list) => {
const idx = list.findIndex((item) => item.id === next.id)
if (idx < 0) return list
const out = list.slice()
out[idx] = next
return out
})
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
sync.set("session", (list) => {
const idx = list.findIndex((item) => item.id === sessionID)
if (idx < 0) return list
const out = list.slice()
out[idx] = { ...out[idx], revert: next }
return out
})
const busy = (sessionID: string) => {
if (sync.data.session_status[sessionID]?.type !== "idle") return true
if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true
return (sync.data.message[sessionID] ?? []).some(
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
)
}
const queuedFollowups = createMemo(() => {
const id = params.id
if (!id) return emptyFollowups
return followup.items[id] ?? emptyFollowups
})
const editingFollowup = createMemo(() => {
const id = params.id
if (!id) return
return followup.edit[id]
})
const sendingFollowup = createMemo(() => {
const id = params.id
if (!id) return
return followup.sending[id]
})
const queueEnabled = createMemo(() => {
const id = params.id
if (!id) return false
return settings.general.followup() === "queue" && busy(id) && !composer.blocked()
})
const followupText = (item: FollowupDraft) => {
const text = item.prompt
.map((part) => {
if (part.type === "image") return `[image:${part.filename}]`
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
return part.content
})
.join("")
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => !!line)
if (text) return text
return `[${language.t("common.attachment")}]`
}
const queueFollowup = (draft: FollowupDraft) => {
setFollowup("items", draft.sessionID, (items) => [
...(items ?? []),
{ id: Identifier.ascending("message"), ...draft },
])
setFollowup("failed", draft.sessionID, undefined)
setFollowup("paused", draft.sessionID, undefined)
}
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve()
if (followup.sending[sessionID]) return Promise.resolve()
if (opts?.manual) setFollowup("paused", sessionID, undefined)
setFollowup("sending", sessionID, id)
setFollowup("failed", sessionID, undefined)
return sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
})
.then((ok) => {
if (ok === false) return
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
if (opts?.manual) resumeScroll()
})
.catch((err) => {
setFollowup("failed", sessionID, id)
fail(err)
})
.finally(() => {
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
})
}
const editFollowup = (id: string) => {
const sessionID = params.id
if (!sessionID) return
if (followup.sending[sessionID]) return
const item = queuedFollowups().find((entry) => entry.id === id)
if (!item) return
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
setFollowup("failed", sessionID, (value) => (value === id ? undefined : value))
setFollowup("edit", sessionID, {
id: item.id,
prompt: item.prompt,
context: item.context,
})
}
const clearFollowupEdit = () => {
const id = params.id
if (!id) return
setFollowup("edit", id, undefined)
}
const halt = (sessionID: string) =>
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
@@ -1275,42 +1495,77 @@ export default function Page() {
}
const revert = (input: { sessionID: string; messageID: string }) => {
if (ui.reverting || ui.restoring) return
const prev = prompt.current().slice()
const last = info()?.revert
const value = draft(input.messageID)
batch(() => {
setUi("reverting", true)
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
return halt(input.sessionID)
.then(() => sdk.client.session.revert(input))
.then(() => {
prompt.set(value)
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
setUi("reverting", false)
})
.catch(fail)
}
const restore = (id: string) => {
const sessionID = params.id
if (!sessionID || ui.restoring) return
if (!sessionID || ui.restoring || ui.reverting) return
const next = userMessages().find((item) => item.id > id)
setUi("restoring", id)
const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
setUi("restoring", id)
setUi("reverting", true)
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next
? halt(sessionID)
.then(() => sdk.client.session.unrevert({ sessionID }))
.then(() => {
prompt.reset()
})
: halt(sessionID)
.then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
.then(() => {
prompt.set(draft(next.id))
})
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
: halt(sessionID).then(() =>
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
return task.catch(fail).finally(() => {
setUi("restoring", (value) => (value === id ? undefined : value))
})
return task
.then((result) => {
if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
batch(() => {
setUi("restoring", (value) => (value === id ? undefined : value))
setUi("reverting", false)
})
})
}
const rolled = createMemo(() => {
@@ -1323,6 +1578,21 @@ export default function Page() {
const actions = { fork, revert }
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
const item = queuedFollowups()[0]
if (!item) return
if (followup.sending[sessionID]) return
if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return
if (composer.blocked()) return
if (busy(sessionID)) return
void sendFollowup(sessionID, item.id)
})
createResizeObserver(
() => promptDock,
({ height }) => {
@@ -1341,6 +1611,7 @@ export default function Page() {
if (stick) autoScroll.forceScrollToBottom()
if (el) scheduleScrollState(el)
fill()
},
)
@@ -1374,6 +1645,7 @@ export default function Page() {
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
})
return (
@@ -1482,11 +1754,33 @@ export default function Page() {
resumeScroll()
}}
onResponseSubmit={resumeScroll}
followup={
params.id
? {
queue: queueEnabled,
items: followupDock(),
sending: sendingFollowup(),
edit: editingFollowup(),
onQueue: queueFollowup,
onAbort: () => {
const id = params.id
if (!id) return
setFollowup("paused", id, true)
},
onSend: (id) => {
void sendFollowup(params.id!, id, { manual: true })
},
onEdit: editFollowup,
onEditLoaded: clearFollowupEdit,
}
: undefined
}
revert={
rolled().length > 0
? {
items: rolled(),
restoring: ui.restoring,
disabled: ui.reverting,
onRestore: restore,
}
: undefined

View File

@@ -8,9 +8,11 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit"
export function SessionComposerRegion(props: {
state: SessionComposerState
@@ -21,9 +23,21 @@ export function SessionComposerRegion(props: {
onNewSessionWorktreeReset: () => void
onSubmit: () => void
onResponseSubmit: () => void
followup?: {
queue: () => boolean
items: { id: string; text: string }[]
sending?: string
edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] }
onQueue: (draft: FollowupDraft) => void
onAbort: () => void
onSend: (id: string) => void
onEdit: (id: string) => void
onEditLoaded: () => void
}
revert?: {
items: { id: string; text: string }[]
restoring?: string
disabled?: boolean
onRestore: (id: string) => void
}
setPromptDockRef: (el: HTMLDivElement) => void
@@ -156,6 +170,7 @@ export function SessionComposerRegion(props: {
<SessionRevertDock
items={revert.items}
restoring={revert.restoring}
disabled={revert.disabled}
onRestore={revert.onRestore}
/>
</div>
@@ -195,7 +210,12 @@ export function SessionComposerRegion(props: {
"margin-top": `${-36 * value()}px`,
}}
>
<SessionRevertDock items={revert.items} restoring={revert.restoring} onRestore={revert.onRestore} />
<SessionRevertDock
items={revert.items}
restoring={revert.restoring}
disabled={revert.disabled}
onRestore={revert.onRestore}
/>
</div>
)}
</Show>
@@ -207,10 +227,23 @@ export function SessionComposerRegion(props: {
"margin-top": `${-lift()}px`,
}}
>
<Show when={props.followup?.items.length}>
<SessionFollowupDock
items={props.followup!.items}
sending={props.followup!.sending}
onSend={props.followup!.onSend}
onEdit={props.followup!.onEdit}
/>
</Show>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
shouldQueue={props.followup?.queue}
onQueue={props.followup?.onQueue}
onAbort={props.followup?.onAbort}
onSubmit={props.onSubmit}
/>
</div>

View File

@@ -0,0 +1,109 @@
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useLanguage } from "@/context/language"
export function SessionFollowupDock(props: {
items: { id: string; text: string }[]
sending?: string
onSend: (id: string) => void
onEdit: (id: string) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({
collapsed: false,
})
const toggle = () => setStore("collapsed", (value) => !value)
const total = createMemo(() => props.items.length)
const label = createMemo(() =>
language.t(total() === 1 ? "session.followupDock.summary.one" : "session.followupDock.summary.other", {
count: total(),
}),
)
const preview = createMemo(() => props.items[0]?.text ?? "")
return (
<DockTray
data-component="session-followup-dock"
style={{
"margin-bottom": "-0.875rem",
"border-bottom-left-radius": 0,
"border-bottom-right-radius": 0,
}}
>
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span class="shrink-0 text-13-medium text-text-strong cursor-default">{label()}</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate text-13-regular text-text-base cursor-default">{preview()}</span>
</Show>
<div class="ml-auto shrink-0">
<IconButton
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.followupDock.expand") : language.t("session.followupDock.collapse")
}
/>
</div>
</div>
<Show when={store.collapsed}>
<div class="h-5" aria-hidden="true" />
</Show>
<Show when={!store.collapsed}>
<div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex items-center gap-2 min-w-0 py-1">
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
class="shrink-0"
disabled={!!props.sending}
onClick={() => props.onSend(item.id)}
>
{language.t("session.followupDock.sendNow")}
</Button>
<Button
size="small"
variant="ghost"
class="shrink-0"
disabled={!!props.sending}
onClick={() => props.onEdit(item.id)}
>
{language.t("session.followupDock.edit")}
</Button>
</div>
)}
</For>
</div>
</Show>
</DockTray>
)
}

View File

@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
sending: false,
collapsed: false,
})
let root: HTMLDivElement | undefined
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const last = createMemo(() => store.tab >= total() - 1)
const fold = () => setStore("collapsed", (value) => !value)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
@@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
kind="question"
ref={(el) => (root = el)}
header={
<>
<div
data-action="session-question-toggle"
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
role="button"
tabIndex={0}
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
onClick={fold}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress">
<div data-slot="question-progress" class="ml-auto mr-1">
<For each={questions()}>
{(_, i) => (
<button
@@ -271,13 +288,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending}
onClick={() => jump(i())}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
jump(i())
}}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
</>
<div>
<IconButton
data-action="session-question-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
fold()
}}
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
/>
</div>
</div>
}
footer={
<>
@@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
<div
data-slot="question-text"
class="cursor-default"
classList={{
"mb-6": store.collapsed && picked() === 0,
}}
role={store.collapsed ? "button" : undefined}
tabIndex={store.collapsed ? 0 : undefined}
onClick={fold}
onKeyDown={(event) => {
if (!store.collapsed) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
fold()
}}
>
{question()?.question}
</div>
<Show when={store.collapsed && picked() > 0}>
<div data-slot="question-hint" class="cursor-default mb-6">
{picked()} answer{picked() === 1 ? "" : "s"} selected
</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-picked={picked()}
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
aria-checked={on()}
disabled={store.sending}
onClick={() => selectOption(i())}
onClick={customOpen}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
onClick={customOpen}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
@@ -365,80 +472,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</div>
</div>
</DockPrompt>
)

View File

@@ -1,4 +1,4 @@
import { For, Show, createMemo } from "solid-js"
import { For, Show, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockTray } from "@opencode-ai/ui/dock-surface"
@@ -8,11 +8,18 @@ import { useLanguage } from "@/context/language"
export function SessionRevertDock(props: {
items: { id: string; text: string }[]
restoring?: string
disabled?: boolean
onRestore: (id: string) => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({
collapsed: false,
collapsed: true,
})
createEffect(() => {
props.items.length
props.items[0]?.id
setStore("collapsed", true)
})
const toggle = () => setStore("collapsed", (value) => !value)
@@ -68,16 +75,16 @@ export function SessionRevertDock(props: {
</Show>
<Show when={!store.collapsed}>
<div class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex items-center gap-2 min-w-0 rounded-[10px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
<div class="flex items-center gap-2 min-w-0 py-1">
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
class="shrink-0"
disabled={!!props.restoring}
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}

View File

@@ -11,15 +11,19 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { TextField } from "@opencode-ai/ui/text-field"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useGlobalSDK } from "@/context/global-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -215,12 +219,14 @@ export function MessageTimeline(props: {
let touchGesture: number | undefined
const navigate = useNavigate()
const globalSDK = useGlobalSDK()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
const { params, sessionKey } = useSessionKey()
const platform = usePlatform()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionID = createMemo(() => params.id)
@@ -298,6 +304,8 @@ export function MessageTimeline(props: {
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 }
@@ -314,9 +322,55 @@ export function MessageTimeline(props: {
saving: false,
menuOpen: false,
pendingRename: false,
pendingShare: false,
})
let titleRef: HTMLInputElement | undefined
const [share, setShare] = createStore({
open: false,
dismiss: null as "escape" | "outside" | null,
})
let more: HTMLButtonElement | undefined
const [req, setReq] = createStore({ share: false, unshare: false })
const shareSession = () => {
const id = sessionID()
if (!id || req.share) return
if (!shareEnabled()) return
setReq("share", true)
globalSDK.client.session
.share({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to share session", err)
})
.finally(() => {
setReq("share", false)
})
}
const unshareSession = () => {
const id = sessionID()
if (!id || req.unshare) return
if (!shareEnabled()) return
setReq("unshare", true)
globalSDK.client.session
.unshare({ sessionID: id, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to unshare session", err)
})
.finally(() => {
setReq("unshare", false)
})
}
const viewShare = () => {
const url = shareUrl()
if (!url) return
platform.openLink(url)
}
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
@@ -329,7 +383,15 @@ export function MessageTimeline(props: {
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
() =>
setTitle({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
pendingShare: false,
}),
{ defer: true },
),
)
@@ -678,23 +740,42 @@ export function MessageTimeline(props: {
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}}
>
<DropdownMenu.Item
@@ -705,6 +786,17 @@ export function MessageTimeline(props: {
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -717,6 +809,104 @@ export function MessageTimeline(props: {
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<KobaltePopover
open={share.open}
anchorRef={() => more}
placement="bottom-end"
gutter={4}
modal={false}
onOpenChange={(open) => {
if (open) setShare("dismiss", null)
setShare("open", open)
}}
>
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}}
>
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={req.share}
>
{req.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={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={unshareSession}
disabled={req.unshare}
>
{req.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={req.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
</div>
)}
</Show>
@@ -752,12 +942,6 @@ export function MessageTimeline(props: {
<For each={rendered()}>
{(messageID) => {
const active = createMemo(() => activeMessageID() === messageID)
const queued = createMemo(() => {
if (active()) return false
const activeID = activeMessageID()
if (activeID) return messageID > activeID
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
})
@@ -817,7 +1001,6 @@ export function MessageTimeline(props: {
messageID={messageID}
actions={props.actions}
active={active()}
queued={queued()}
status={active() ? sessionStatus() : undefined}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.24",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -136,6 +136,11 @@ export async function handler(
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v),
),
},
authInfo?.workspaceID,
),

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.24",
"version": "1.2.25",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -36,6 +36,7 @@ export namespace ZenData {
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
}),
),
})
@@ -46,6 +47,7 @@ export namespace ZenData {
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(),
})
const ModelsSchema = z.object({

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.24",
"version": "1.2.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.24",
"version": "1.2.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.24",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -5,7 +5,7 @@ import { createServer } from "node:net"
import { homedir } from "node:os"
import { join } from "node:path"
import type { Event } from "electron"
import { app, type BrowserWindow, dialog } from "electron"
import { app, BrowserWindow, dialog } from "electron"
import pkg from "electron-updater"
const APP_NAMES: Record<string, string> = {
@@ -32,7 +32,7 @@ import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
@@ -156,12 +156,9 @@ async function initialize() {
const globals = {
updaterEnabled: UPDATER_ENABLED,
wsl: getWslConfig().enabled,
deepLinks: pendingDeepLinks,
}
wireMenu()
if (needsMigration) {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
@@ -178,6 +175,7 @@ async function initialize() {
}
mainWindow = createMainWindow(globals)
wireMenu()
overlay?.close()
}
@@ -231,6 +229,7 @@ registerIpcHandlers({
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(),
setBackgroundColor: (color) => setBackgroundColor(color),
})
function killSidecar() {

View File

@@ -24,6 +24,7 @@ type Deps = {
runUpdater: (alertOnFail: boolean) => Promise<void> | void
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> | void
setBackgroundColor: (color: string) => void
}
export function registerIpcHandlers(deps: Deps) {
@@ -53,6 +54,7 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
const store = getStore(name)
const value = store.get(key)
@@ -140,6 +142,8 @@ export function registerIpcHandlers(deps: Deps) {
new Notification({ title, body }).show()
})
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false

View File

@@ -1,6 +1,7 @@
import { BrowserWindow, Menu, shell } from "electron"
import { UPDATER_ENABLED } from "./constants"
import { createMainWindow } from "./windows"
type Deps = {
trigger: (id: string) => void
@@ -48,6 +49,11 @@ export function createMenu(deps: Deps) {
submenu: [
{ label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
{ label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
{
label: "New Window",
accelerator: "Cmd+Shift+N",
click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }),
},
{ type: "separator" },
{ role: "close" },
],

View File

@@ -6,12 +6,21 @@ import type { TitlebarTheme } from "../preload/types"
type Globals = {
updaterEnabled: boolean
wsl: boolean
deepLinks?: string[]
}
const root = dirname(fileURLToPath(import.meta.url))
let backgroundColor: string | undefined
export function setBackgroundColor(color: string) {
backgroundColor = color
}
export function getBackgroundColor(): string | undefined {
return backgroundColor
}
function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
}
@@ -59,6 +68,7 @@ export function createMainWindow(globals: Globals) {
show: true,
title: "OpenCode",
icon: iconPath(),
backgroundColor,
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
@@ -95,6 +105,7 @@ export function createLoadingWindow(globals: Globals) {
center: true,
show: true,
icon: iconPath(),
backgroundColor,
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
...(process.platform === "win32"
? {
@@ -131,7 +142,6 @@ function injectGlobals(win: BrowserWindow, globals: Globals) {
const deepLinks = globals.deepLinks ?? []
const data = {
updaterEnabled: globals.updaterEnabled,
wsl: globals.wsl,
deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
}
void win.webContents.executeJavaScript(

View File

@@ -28,6 +28,7 @@ const api: ElectronAPI = {
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
storeLength: (name) => ipcRenderer.invoke("store-length", name),
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
onSqliteMigrationProgress: (cb) => {
const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress)
ipcRenderer.on("sqlite-migration-progress", handler)
@@ -62,6 +63,7 @@ const api: ElectronAPI = {
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"),
installUpdate: () => ipcRenderer.invoke("install-update"),
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
}
contextBridge.exposeInMainWorld("api", api)

View File

@@ -36,6 +36,7 @@ export type ElectronAPI = {
storeKeys: (name: string) => Promise<string[]>
storeLength: (name: string) => Promise<number>
getWindowCount: () => Promise<number>
onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
onMenuCommand: (cb: (id: string) => void) => () => void
onDeepLink: (cb: (urls: string[]) => void) => () => void
@@ -66,4 +67,5 @@ export type ElectronAPI = {
runUpdater: (alertOnFail: boolean) => Promise<void>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void>
setBackgroundColor: (color: string) => Promise<void>
}

View File

@@ -10,14 +10,15 @@ import {
useCommand,
} from "@opencode-ai/app"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createResource, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { useTheme } from "@opencode-ai/ui/theme"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -226,7 +227,9 @@ const createPlatform = (): Platform => {
const image = await window.api.readClipboardImage().catch(() => null)
if (!image) return null
const blob = new Blob([image.buffer], { type: "image/png" })
return new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })
return new File([blob], `pasted-image-${Date.now()}.png`, {
type: "image/png",
})
},
}
}
@@ -240,6 +243,8 @@ listenForDeepLinks()
render(() => {
const platform = createPlatform()
const [windowCount] = createResource(() => window.api.getWindowCount())
// Fetch sidecar credentials (available immediately, before health check)
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
@@ -276,6 +281,18 @@ render(() => {
function Inner() {
const cmd = useCommand()
menuTrigger = (id) => cmd.trigger(id)
const theme = useTheme()
createEffect(() => {
theme.themeId()
theme.mode()
const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
if (bg) {
void window.api.setBackgroundColor(bg)
}
})
return null
}
@@ -289,13 +306,14 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders>
<Show when={!defaultServer.loading && !sidecar.loading}>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
{(_) => {
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
disableHealthCheck={(windowCount() ?? 0) > 1}
>
<Inner />
</AppInterface>

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.24",
"version": "1.2.25",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,12 +1,11 @@
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
import { openUrl } from "@tauri-apps/plugin-opener"
import { type as ostype } from "@tauri-apps/plugin-os"
import { relaunch } from "@tauri-apps/plugin-process"
import { openUrl } from "@tauri-apps/plugin-opener"
import { runUpdater, UPDATER_ENABLED } from "./updater"
import { commands } from "./bindings"
import { installCli } from "./cli"
import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
import { runUpdater, UPDATER_ENABLED } from "./updater"
export async function createMenu(trigger: (id: string) => void) {
if (ostype() !== "macos") return

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.24",
"version": "1.2.25",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.24"
version = "1.2.25"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.25/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.24",
"version": "1.2.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,13 +1,13 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.24",
"version": "1.2.25",
"name": "opencode",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"test": "bun test --timeout 30000 registry",
"build": "bun run script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
@@ -25,9 +25,15 @@
"exports": {
"./*": "./src/*.ts"
},
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
}
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -43,13 +49,14 @@
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"drizzle-kit": "catalog:",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -82,9 +89,12 @@
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -93,8 +103,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@opentui/core": "0.1.86",
"@opentui/solid": "0.1.86",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -109,8 +119,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"effect": "catalog:",
"drizzle-orm": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")

View File

@@ -64,6 +64,7 @@ export namespace Agent {
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

@@ -1,9 +1,13 @@
import path from "path"
import { Global } from "../global"
import { Effect } from "effect"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { runtime } from "@/effect/runtime"
import * as S from "./service"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export { OAUTH_DUMMY_KEY } from "./service"
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
return runtime.runPromise(S.AuthService.use(f))
}
export namespace Auth {
export const Oauth = z
@@ -35,39 +39,19 @@ export namespace Auth {
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
export type Info = z.infer<typeof Info>
const filepath = path.join(Global.Path.data, "auth.json")
export async function get(providerID: string) {
const auth = await all()
return auth[providerID]
return runPromise((service) => service.get(providerID))
}
export async function all(): Promise<Record<string, Info>> {
const data = await Filesystem.readJson<Record<string, unknown>>(filepath).catch(() => ({}))
return Object.entries(data).reduce(
(acc, [key, value]) => {
const parsed = Info.safeParse(value)
if (!parsed.success) return acc
acc[key] = parsed.data
return acc
},
{} as Record<string, Info>,
)
return runPromise((service) => service.all())
}
export async function set(key: string, info: Info) {
const normalized = key.replace(/\/+$/, "")
const data = await all()
if (normalized !== key) delete data[key]
delete data[normalized + "/"]
await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600)
return runPromise((service) => service.set(key, info))
}
export async function remove(key: string) {
const normalized = key.replace(/\/+$/, "")
const data = await all()
delete data[key]
delete data[normalized]
await Filesystem.writeJson(filepath, data, 0o600)
return runPromise((service) => service.remove(key))
}
}

View File

@@ -0,0 +1,101 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
export namespace AuthService {
export interface Service {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
}
}
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
static readonly layer = Layer.effect(
AuthService,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("AuthService.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return AuthService.of({
get,
all,
set,
remove,
})
}),
)
static readonly defaultLayer = AuthService.layer
}

View File

@@ -1,13 +1,5 @@
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { text } from "node:stream/consumers"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process"
export namespace BunProc {
@@ -45,87 +37,4 @@ export namespace BunProc {
export function which() {
return process.execPath
}
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version !== "latest" && cachedVersion === version) {
return mod
} else if (version === "latest") {
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!isOutdated) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
}

View File

@@ -1,4 +1,3 @@
import semver from "semver"
import { Log } from "../util/log"
import { Process } from "../util/process"
@@ -28,17 +27,4 @@ export namespace PackageRegistry {
if (!value) return null
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
}

View File

@@ -11,6 +11,11 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const isActiveOrgChoice = (
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
choice: { accountID: AccountID; orgID: OrgID },
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
@@ -99,11 +104,10 @@ const switchEffect = Effect.fn("switch")(function* () {
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
@@ -132,11 +136,10 @@ const orgsEffect = Effect.fn("orgs")(function* () {
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
@@ -192,3 +195,28 @@ export const OrgsCommand = cmd({
await runtime.runPromise(orgsEffect())
},
})
export const ConsoleCommand = cmd({
command: "console",
describe: "manage console account",
builder: (yargs) =>
yargs
.command({
...LoginCommand,
describe: "log in to console",
})
.command({
...LogoutCommand,
describe: "log out from console",
})
.command({
...SwitchCommand,
describe: "switch active org",
})
.command({
...OrgsCommand,
describe: "list orgs",
})
.demandCommand(),
async handler() {},
})

View File

@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,

View File

@@ -318,10 +318,10 @@ export const ProvidersLoginCommand = cmd({
const priority: Record<string, number> = {
opencode: 0,
anthropic: 1,
openai: 1,
"github-copilot": 2,
openai: 3,
google: 4,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}

View File

@@ -370,6 +370,11 @@ export const RunCommand = cmd({
action: "deny",
pattern: "*",
},
{
permission: "edit",
action: "allow",
pattern: "*",
},
]
function title() {

View File

@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})

View File

@@ -480,6 +480,7 @@ function App() {
{
title: "Toggle MCPs",
value: "mcp.list",
search: "toggle mcps",
category: "Agent",
slash: {
name: "mcps",
@@ -555,8 +556,9 @@ function App() {
category: "System",
},
{
title: "Toggle appearance",
title: mode() === "dark" ? "Light mode" : "Dark mode",
value: "theme.switch_mode",
search: "toggle appearance",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -595,6 +597,7 @@ function App() {
},
{
title: "Toggle debug panel",
search: "toggle debug",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
@@ -604,6 +607,7 @@ function App() {
},
{
title: "Toggle console",
search: "toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
@@ -644,6 +648,7 @@ function App() {
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
search: "toggle terminal title",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
@@ -659,6 +664,7 @@ function App() {
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
search: "toggle animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
@@ -668,6 +674,7 @@ function App() {
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
search: "toggle diff wrapping",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")
@@ -677,20 +684,6 @@ function App() {
},
])
createEffect(() => {
const currentModel = local.model.current()
if (!currentModel) return
if (currentModel.providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
untrack(() => {
DialogAlert.show(
dialog,
"Warning",
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
).then(() => kv.set("openrouter_warning", true))
})
}
})
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})

View File

@@ -7,6 +7,27 @@ import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import type { Provider } from "@opencode-ai/sdk/v2"
function pickLatest(models: [string, Provider["models"][string]][]) {
const picks: Record<string, [string, Provider["models"][string]]> = {}
for (const item of models) {
const model = item[0]
const info = item[1]
const key = info.family ?? model
const prev = picks[key]
if (!prev) {
picks[key] = item
continue
}
if (info.release_date !== prev[1].release_date) {
if (info.release_date > prev[1].release_date) picks[key] = item
continue
}
if (model > prev[0]) picks[key] = item
}
return Object.values(picks)
}
export function useConnected() {
const sync = useSync()
@@ -21,6 +42,7 @@ export function DialogModel(props: { providerID?: string }) {
const dialog = useDialog()
const keybind = useKeybind()
const [query, setQuery] = createSignal("")
const [all, setAll] = createSignal(false)
const connected = useConnected()
const providers = createDialogProviderOptions()
@@ -72,8 +94,8 @@ export function DialogModel(props: { providerID?: string }) {
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
flatMap((provider) => {
const items = pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
@@ -104,8 +126,9 @@ export function DialogModel(props: { providerID?: string }) {
(x) => x.footer !== "Free",
(x) => x.title,
),
),
),
)
return items
}),
)
const popularProviders = !connected()
@@ -154,6 +177,13 @@ export function DialogModel(props: { providerID?: string }) {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
{
keybind: keybind.all.model_show_all_toggle?.[0],
title: all() ? "Show latest only" : "Show all models",
onTrigger: () => {
setAll((value) => !value)
},
},
]}
onFilter={setQuery}
flat={true}

View File

@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await Bun.sleep(1000)
await sleep(1000)
continue
}
if (!result.data) {

View File

@@ -78,6 +78,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
function promptModelWarning() {
toast.show({
@@ -171,6 +172,17 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
value: "permission.auto_accept.toggle",
search: "toggle permissions",
keybind: "permission_auto_accept_toggle",
category: "Agent",
onSelect: (dialog) => {
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
value: "prompt.clear",
@@ -1012,23 +1024,30 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
</Show>
</box>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</Show>
</box>
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
</box>
</box>

View File

@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { useKV } from "./kv"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
@@ -106,6 +107,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
async function syncWorkspaces() {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
@@ -136,6 +139,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
@@ -451,6 +461,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)

View File

@@ -47,6 +47,7 @@ export function Home() {
{
title: tipsHidden() ? "Show tips" : "Hide tips",
value: "tips.toggle",
search: "toggle tips",
keybind: "tips_toggle",
category: "System",
onSelect: (dialog) => {

View File

@@ -568,6 +568,7 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
search: "toggle sidebar",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -582,6 +583,7 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
search: "toggle code concealment",
keybind: "messages_toggle_conceal" as any,
category: "Session",
onSelect: (dialog) => {
@@ -592,6 +594,7 @@ export function Session() {
{
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
search: "toggle timestamps",
category: "Session",
slash: {
name: "timestamps",
@@ -605,6 +608,7 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
search: "toggle thinking",
keybind: "display_thinking",
category: "Session",
slash: {
@@ -619,6 +623,7 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
search: "toggle tool details",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
@@ -627,8 +632,9 @@ export function Session() {
},
},
{
title: "Toggle session scrollbar",
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
value: "session.toggle.scrollbar",
search: "toggle session scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -907,12 +913,12 @@ export function Session() {
const filename = options.filename.trim()
const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript)
await Filesystem.write(filepath, transcript)
// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) {
await Bun.write(filepath, result)
await Filesystem.write(filepath, result)
}
toast.show({ message: `Session exported to ${filename}`, variant: "success" })

View File

@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
search?: string
footer?: JSX.Element | string
category?: string
disabled?: boolean
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
keys: ["title", "category", "search"],
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
})
.map((x) => x.obj)

View File

@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Bun.Server<BunWebSocketData> | undefined
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStream = {
abort: undefined as AbortController | undefined,
@@ -120,7 +119,7 @@ export const rpc = {
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = Server.listen(input)
server = await Server.listen(input)
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
@@ -143,7 +142,7 @@ export const rpc = {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll()
if (server) server.stop(true)
if (server) await server.stop(true)
},
}

View File

@@ -37,7 +37,7 @@ export const WebCommand = cmd({
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const server = await Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()

View File

@@ -1,6 +1,6 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import { pathToFileURL } from "url"
import { createRequire } from "module"
import os from "os"
import z from "zod"
@@ -22,7 +22,6 @@ import {
} from "jsonc-parser"
import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
@@ -30,12 +29,11 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Npm } from "@/npm"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -152,8 +150,7 @@ export namespace Config {
deps.push(
iife(async () => {
const shouldInstall = await needsInstall(dir)
if (shouldInstall) await installDependencies(dir)
await installDependencies(dir)
}),
)
@@ -269,6 +266,10 @@ export namespace Config {
}
export async function installDependencies(dir: string) {
if (!(await isWritable(dir))) {
log.info("config dir is not writable, skipping dependency install", { dir })
return
}
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@@ -282,22 +283,15 @@ export namespace Config {
await Filesystem.writeJson(pkg, json)
const gitignore = path.join(dir, ".gitignore")
const hasGitIgnore = await Filesystem.exists(gitignore)
if (!hasGitIgnore)
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
if (!(await Filesystem.exists(gitignore)))
await Filesystem.write(
gitignore,
["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
)
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch((err) => {
log.warn("failed to install dependencies", { dir, error: err })
})
await Npm.install(dir)
}
async function isWritable(dir: string) {
@@ -309,41 +303,6 @@ export namespace Config {
}
}
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
if (!writable) {
log.debug("config dir is not writable, skipping dependency install", { dir })
return false
}
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!isOutdated) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) {
@@ -795,6 +754,7 @@ export namespace Config {
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
@@ -835,7 +795,12 @@ export namespace Config {
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
permission_auto_accept_toggle: z
.string()
.optional()
.default("shift+tab")
.describe("Toggle auto-accept mode for permissions"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),

View File

@@ -1,3 +1,4 @@
import { createAdaptorServer } from "@hono/node-server"
import { Hono } from "hono"
import { Instance } from "../../project/instance"
import { InstanceBootstrap } from "../../project/bootstrap"
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
}
export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({
hostname: opts.hostname,
port: opts.port,
const server = createAdaptorServer({
fetch: App().fetch,
})
server.listen(opts.port, opts.hostname)
return {
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
},
}
}
}

View File

@@ -1,4 +1,5 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
@@ -117,7 +118,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) {
await Bun.sleep(1000)
await sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
@@ -127,7 +128,7 @@ export namespace Workspace {
})
})
// Wait 250ms and retry if SSE connection fails
await Bun.sleep(250)
await sleep(250)
}
}

View File

@@ -1,4 +1,5 @@
import { ManagedRuntime } from "effect"
import { Layer, ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))

View File

@@ -1,40 +1,40 @@
import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
import { Npm } from "@/npm"
export interface Info {
name: string
command: string[]
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<boolean>
enabled(): Promise<string[] | false>
}
export const gofmt: Info = {
name: "gofmt",
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return which("gofmt") !== null
const p = which("gofmt")
if (p === null) return false
return [p, "-w", "$FILE"]
},
}
export const mix: Info = {
name: "mix",
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return which("mix") !== null
const p = which("mix")
if (p === null) return false
return [p, "format", "$FILE"]
},
}
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -73,8 +73,9 @@ export const prettier: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.prettier) return true
if (json.devDependencies?.prettier) return true
if (json.dependencies?.prettier || json.devDependencies?.prettier) {
return [await Npm.which("prettier"), "--write", "$FILE"]
}
}
return false
},
@@ -82,7 +83,6 @@ export const prettier: Info = {
export const oxfmt: Info = {
name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -95,8 +95,9 @@ export const oxfmt: Info = {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}>(item)
if (json.dependencies?.oxfmt) return true
if (json.devDependencies?.oxfmt) return true
if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) {
return [await Npm.which("oxfmt"), "$FILE"]
}
}
return false
},
@@ -104,7 +105,6 @@ export const oxfmt: Info = {
export const biome: Info = {
name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -141,7 +141,7 @@ export const biome: Info = {
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
return true
return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"]
}
}
return false
@@ -150,47 +150,49 @@ export const biome: Info = {
export const zig: Info = {
name: "zig",
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return which("zig") !== null
const p = which("zig")
if (p === null) return false
return [p, "fmt", "$FILE"]
},
}
export const clang: Info = {
name: "clang-format",
command: ["clang-format", "-i", "$FILE"],
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
return items.length > 0
if (items.length === 0) return false
return ["clang-format", "-i", "$FILE"]
},
}
export const ktlint: Info = {
name: "ktlint",
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return which("ktlint") !== null
const p = which("ktlint")
if (p === null) return false
return [p, "-F", "$FILE"]
},
}
export const ruff: Info = {
name: "ruff",
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (!which("ruff")) return false
const p = which("ruff")
if (p === null) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0])
if (content.includes("[tool.ruff]")) return true
if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"]
} else {
return true
return [p, "format", "$FILE"]
}
}
}
@@ -199,7 +201,7 @@ export const ruff: Info = {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
if (found.length > 0) {
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return true
if (content.includes("ruff")) return [p, "format", "$FILE"]
}
}
return false
@@ -208,14 +210,13 @@ export const ruff: Info = {
export const rlang: Info = {
name: "air",
command: ["air", "format", "$FILE"],
extensions: [".R"],
async enabled() {
const airPath = which("air")
if (airPath == null) return false
try {
const proc = Process.spawn(["air", "--help"], {
const proc = Process.spawn([airPath, "--help"], {
stdout: "pipe",
stderr: "pipe",
})
@@ -227,7 +228,10 @@ export const rlang: Info = {
const firstLine = output.split("\n")[0]
const hasR = firstLine.includes("R language")
const hasFormatter = firstLine.includes("formatter")
return hasR && hasFormatter
if (hasR && hasFormatter) {
return [airPath, "format", "$FILE"]
}
return false
} catch (error) {
return false
}
@@ -236,14 +240,14 @@ export const rlang: Info = {
export const uvformat: Info = {
name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const uvPath = which("uv")
if (uvPath !== null) {
const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
if (code === 0) return [uvPath, "format", "--", "$FILE"]
}
return false
},
@@ -251,108 +255,118 @@ export const uvformat: Info = {
export const rubocop: Info = {
name: "rubocop",
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("rubocop") !== null
const path = which("rubocop")
if (path === null) return false
return [path, "--autocorrect", "$FILE"]
},
}
export const standardrb: Info = {
name: "standardrb",
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return which("standardrb") !== null
const path = which("standardrb")
if (path === null) return false
return [path, "--fix", "$FILE"]
},
}
export const htmlbeautifier: Info = {
name: "htmlbeautifier",
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return which("htmlbeautifier") !== null
const path = which("htmlbeautifier")
if (path === null) return false
return [path, "$FILE"]
},
}
export const dart: Info = {
name: "dart",
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
return which("dart") !== null
const path = which("dart")
if (path === null) return false
return [path, "format", "$FILE"]
},
}
export const ocamlformat: Info = {
name: "ocamlformat",
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"],
async enabled() {
if (!which("ocamlformat")) return false
const path = which("ocamlformat")
if (!path) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0
if (items.length === 0) return false
return [path, "-i", "$FILE"]
},
}
export const terraform: Info = {
name: "terraform",
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"],
async enabled() {
return which("terraform") !== null
const path = which("terraform")
if (path === null) return false
return [path, "fmt", "$FILE"]
},
}
export const latexindent: Info = {
name: "latexindent",
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"],
async enabled() {
return which("latexindent") !== null
const path = which("latexindent")
if (path === null) return false
return [path, "-w", "-s", "$FILE"]
},
}
export const gleam: Info = {
name: "gleam",
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"],
async enabled() {
return which("gleam") !== null
const path = which("gleam")
if (path === null) return false
return [path, "format", "$FILE"]
},
}
export const shfmt: Info = {
name: "shfmt",
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
return which("shfmt") !== null
const path = which("shfmt")
if (path === null) return false
return [path, "-w", "$FILE"]
},
}
export const nixfmt: Info = {
name: "nixfmt",
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
return which("nixfmt") !== null
const path = which("nixfmt")
if (path === null) return false
return [path, "$FILE"]
},
}
export const rustfmt: Info = {
name: "rustfmt",
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return which("rustfmt") !== null
const path = which("rustfmt")
if (path === null) return false
return [path, "$FILE"]
},
}
export const pint: Info = {
name: "pint",
command: ["./vendor/bin/pint", "$FILE"],
extensions: [".php"],
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
@@ -361,8 +375,9 @@ export const pint: Info = {
require?: Record<string, string>
"require-dev"?: Record<string, string>
}>(item)
if (json.require?.["laravel/pint"]) return true
if (json["require-dev"]?.["laravel/pint"]) return true
if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) {
return ["./vendor/bin/pint", "$FILE"]
}
}
return false
},
@@ -370,27 +385,30 @@ export const pint: Info = {
export const ormolu: Info = {
name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return which("ormolu") !== null
const path = which("ormolu")
if (path === null) return false
return [path, "-i", "$FILE"]
},
}
export const cljfmt: Info = {
name: "cljfmt",
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() {
return which("cljfmt") !== null
const path = which("cljfmt")
if (path === null) return false
return [path, "fix", "--quiet", "$FILE"]
},
}
export const dfmt: Info = {
name: "dfmt",
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"],
async enabled() {
return which("dfmt") !== null
const path = which("dfmt")
if (path === null) return false
return [path, "-i", "$FILE"]
},
}

View File

@@ -25,14 +25,14 @@ export namespace Format {
export type Status = z.infer<typeof Status>
const state = Instance.state(async () => {
const enabled: Record<string, boolean> = {}
const cache: Record<string, string[] | false> = {}
const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
cache,
formatters,
}
}
@@ -46,43 +46,41 @@ export namespace Format {
continue
}
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (result.command.length === 0) continue
result.enabled = async () => true
result.enabled = async () => item.command ?? false
result.name = name
formatters[name] = result
}
return {
enabled,
cache,
formatters,
}
})
async function isEnabled(item: Formatter.Info) {
async function resolveCommand(item: Formatter.Info) {
const s = await state()
let status = s.enabled[item.name]
if (status === undefined) {
status = await item.enabled()
s.enabled[item.name] = status
let command = s.cache[item.name]
if (command === undefined) {
log.info("resolving command", { name: item.name })
command = await item.enabled()
s.cache[item.name] = command
}
return status
return command
}
async function getFormatter(ext: string) {
const formatters = await state().then((x) => x.formatters)
const result = []
const result: { info: Formatter.Info; command: string[] }[] = []
for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext })
if (!item.extensions.includes(ext)) continue
if (!(await isEnabled(item))) continue
const command = await resolveCommand(item)
if (!command) continue
log.info("enabled", { name: item.name, ext })
result.push(item)
result.push({ info: item, command })
}
return result
}
@@ -91,11 +89,11 @@ export namespace Format {
const s = await state()
const result: Status[] = []
for (const formatter of Object.values(s.formatters)) {
const enabled = await isEnabled(formatter)
const command = await resolveCommand(formatter)
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled,
enabled: !!command,
})
}
return result
@@ -108,29 +106,27 @@ export namespace Format {
log.info("formatting", { file })
const ext = path.extname(file)
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
for (const { info, command } of await getFormatter(ext)) {
const replaced = command.map((x) => x.replace("$FILE", file))
log.info("running", { replaced })
try {
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const proc = Process.spawn(replaced, {
cwd: Instance.directory,
env: { ...process.env, ...info.environment },
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {
command: item.command,
...item.environment,
command,
...info.environment,
})
} catch (error) {
log.error("failed to format file", {
error,
command: item.command,
...item.environment,
command,
...info.environment,
file,
})
}

View File

@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir()
},
data,
bin: path.join(data, "bin"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
cache,
config,

View File

@@ -3,7 +3,7 @@ import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import { Log } from "./util/log"
import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
import { ConsoleCommand } from "./cli/cmd/account"
import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent"
import { UpgradeCommand } from "./cli/cmd/upgrade"
@@ -47,11 +47,6 @@ process.on("uncaughtException", (e) => {
})
})
// Ensure the process exits on terminal hangup (eg. closing the terminal tab).
// Without this, long-running commands like `serve` block on a never-resolving
// promise and survive as orphaned processes.
process.on("SIGHUP", () => process.exit())
let cli = yargs(hideBin(process.argv))
.parserConfiguration({ "populate--": true })
.scriptName("opencode")
@@ -135,10 +130,7 @@ let cli = yargs(hideBin(process.argv))
.command(RunCommand)
.command(GenerateCommand)
.command(DebugCommand)
.command(LoginCommand)
.command(LogoutCommand)
.command(SwitchCommand)
.command(OrgsCommand)
.command(ConsoleCommand)
.command(ProvidersCommand)
.command(AgentCommand)
.command(UpgradeCommand)

View File

@@ -3,7 +3,6 @@ import path from "path"
import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -13,6 +12,7 @@ import { Archive } from "../util/archive"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
import { Npm } from "@/npm"
const spawn = ((cmd, args, opts) => {
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
@@ -107,7 +107,7 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], {
cwd: root,
env: {
...process.env,
@@ -133,29 +133,8 @@ export namespace LSPServer {
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"@vue",
"language-server",
"bin",
"vue-language-server.js",
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@vue/language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -218,7 +197,7 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath })
}
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], {
cwd: root,
env: {
...process.env,
@@ -349,8 +328,8 @@ export namespace LSPServer {
if (!bin) {
const resolved = Module.resolve("biome", root)
if (!resolved) return
bin = BunProc.which()
args = ["x", "biome", "lsp-proxy", "--stdio"]
bin = await Npm.which("biome")
args = ["lsp-proxy", "--stdio"]
}
const proc = spawn(bin, args, {
@@ -376,9 +355,7 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("gopls")
if (!bin) {
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -413,9 +390,7 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("rubocop")
if (!bin) {
const ruby = which("ruby")
const gem = which("gem")
@@ -520,19 +495,8 @@ export namespace LSPServer {
let binary = which("pyright-langserver")
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
}).exited
}
binary = BunProc.which()
args.push(...["run", js])
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("pyright")
}
args.push("--stdio")
@@ -634,9 +598,7 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("zls")
if (!bin) {
const zig = which("zig")
@@ -746,9 +708,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("csharp-ls")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
@@ -785,9 +745,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("fsautocomplete")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
@@ -1053,22 +1011,8 @@ export namespace LSPServer {
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("svelte-language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1100,22 +1044,8 @@ export namespace LSPServer {
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("@astrojs/language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1364,31 +1294,8 @@ export namespace LSPServer {
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("yaml-language-server")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1417,9 +1324,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("lua-language-server")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1555,22 +1460,8 @@ export namespace LSPServer {
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("intelephense")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1652,22 +1543,8 @@ export namespace LSPServer {
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("bash-language-server")
}
args.push("start")
const proc = spawn(binary, args, {
@@ -1688,9 +1565,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("terraform-ls")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1771,9 +1646,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("texlab")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1864,22 +1737,8 @@ export namespace LSPServer {
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
binary = await Npm.which("dockerfile-language-server-nodejs")
}
args.push("--stdio")
const proc = spawn(binary, args, {
@@ -1970,9 +1829,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("tinymist")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return

View File

@@ -11,6 +11,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
@@ -166,14 +167,10 @@ export namespace MCP {
const queue = [pid]
while (queue.length > 0) {
const current = queue.shift()!
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
for (const tok of lines) {
const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
if (!isNaN(cpid) && !pids.includes(cpid)) {
pids.push(cpid)
queue.push(cpid)
}

View File

@@ -1,4 +1,5 @@
import { createConnection } from "net"
import { createServer } from "http"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -52,11 +53,74 @@ interface PendingAuth {
}
export namespace McpOAuthCallback {
let server: ReturnType<typeof Bun.serve> | undefined
let server: ReturnType<typeof createServer> | undefined
const pendingAuths = new Map<string, PendingAuth>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404)
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
export async function ensureRunning(): Promise<void> {
if (server) return
@@ -66,75 +130,14 @@ export namespace McpOAuthCallback {
return
}
server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
return new Response("Not found", { status: 404 })
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
if (error) {
const errorMsg = errorDescription || error
if (pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
return new Response(HTML_ERROR("No authorization code provided"), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
// Validate state parameter
if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
},
server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => {
server!.listen(OAUTH_CALLBACK_PORT, () => {
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
resolve()
})
server!.on("error", reject)
})
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(oauthState: string): Promise<string> {
@@ -174,7 +177,7 @@ export namespace McpOAuthCallback {
export async function stop(): Promise<void> {
if (server) {
server.stop()
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined
log.info("oauth callback server stopped")
}

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