Compare commits

...

128 Commits

Author SHA1 Message Date
Ryan Vogel
f49834f301 Merge branch 'dev' into docs-export 2026-02-04 09:38:12 -05:00
R44VC0RP
93356021f4 docs(cli): add example for redirecting export output to file 2026-02-04 09:37:23 -05:00
Adam
c8622df762 fix(app): file tree not staying in sync across projects/sessions 2026-02-04 07:59:46 -06:00
Adam
c277ee8cbf fix(app): move session options to the session page 2026-02-04 07:12:19 -06:00
Adam
a2face30f4 wip(app): session options 2026-02-04 07:12:18 -06:00
Adam
a219615fe5 fix(app): opened tabs follow created session 2026-02-04 06:22:55 -06:00
opencode-agent[bot]
af06175b1f chore: generate 2026-02-04 11:35:05 +00:00
Goni Zahavy
2e8d8de58b fix(desktop): removed compression from rpm bundle to save 15m in CI (#12097) 2026-02-04 05:34:18 -06:00
opencode-agent[bot]
310de8b1ea chore: update nix node_modules hashes 2026-02-04 11:12:01 +00:00
Lucas (TaeYoung) Jo
891875402c fix(terminal): support remote server connections and fix GLIBC compatibility (#11906) 2026-02-04 05:01:10 -06:00
opencode
154cbf6996 release: v1.1.51 2026-02-04 07:06:48 +00:00
Sebastian
64bafce665 restore direct osc52 (#12071) 2026-02-04 00:35:57 -06:00
Aiden Cline
5588453cbe fix: revert change that caused headers to be double merged if provider was authed in multiple places (#12072) 2026-02-04 06:30:32 +00:00
Gary Chu
5aaf8f8247 docs: add agent-compatible paths to skills documentation (#12067) 2026-02-04 00:12:24 -06:00
Aiden Cline
8c1f1f13dc docs: document the built in agents (#12066) 2026-02-04 00:00:05 -06:00
Sergiy Dybskiy
b942e0b4dc fix: prevent double-prefixing of Bedrock cross-region inference models (#12056) 2026-02-03 23:06:17 -06:00
Devin Griffin
f282613746 fix(app): tighten up session padding-top for mobile (#11247)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-02-04 12:10:56 +08:00
Brendan Allan
7c440ae82c chore: add brendonovich as rust codeowner 2026-02-04 10:40:08 +08:00
Brendan Allan
b7bd561eaa ci: use numeric release id instead of gql one 2026-02-04 10:39:17 +08:00
Łukasz Gładysz
6daa962aaa fix: prioritize OPENCODE_CONFIG_DIR for AGENTS.md (#11536) 2026-02-04 01:35:22 +00:00
opencode
93a07e5a2a release: v1.1.50 2026-02-04 01:35:15 +00:00
Max Leiter
93e060272a fix: prevent memory leaks from AbortController closures (#12024) 2026-02-03 17:51:26 -06:00
Filip
acac05f22e refactor(e2e): faster tests (#12021) 2026-02-03 17:45:49 -06:00
Aiden Cline
b5a4671c64 Revert "feat: add Trinity model system prompt support" (#12029) 2026-02-03 16:39:03 -06:00
Aiden Cline
a68fedd4a6 chore: adjust way skill dirs are whitelisted (#12026) 2026-02-03 16:33:36 -06:00
Mariam Jabara
015cd404e4 feat: add Trinity model system prompt support (#12025)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 16:33:27 -06:00
opencode-agent[bot]
9921809565 chore: update nix node_modules hashes 2026-02-03 21:28:16 +00:00
opencode-agent[bot]
137336f373 chore: generate 2026-02-03 21:22:33 +00:00
Sayeed Mahmoud
d940d17918 docs: fix grammar and formatting in README (#11985) 2026-02-03 15:21:51 -06:00
Tomáš Linhart
0a5d5bc524 docs: fix logging example for plugin (#11989) 2026-02-03 15:21:15 -06:00
Tyler Gannon
a30696f9bf feat(plugin): add shell.env hook for manipulating environment in tools and shell (#12012) 2026-02-03 15:18:41 -06:00
Matt Silverlock
25bdd77b1d fix(opencode): use official ai-gateway-provider package for Cloudflare AI Gateway (#12014) 2026-02-03 15:18:28 -06:00
opencode-agent[bot]
2f12e8ee92 chore: generate 2026-02-03 21:10:08 +00:00
Idris Gadi
95211a8854 feat(tui): allow theme colors in agent customization (#11444) 2026-02-03 15:09:21 -06:00
Idris Gadi
6b5cf936a2 docs: add missing environmental flags to the list (#11146) 2026-02-03 15:07:40 -06:00
Dax
17e62b050f feat: add support for reading skills from .agents/skills directories (#11842)
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-02-03 15:51:54 -05:00
Dax
ee84eb44ee cli: add --thinking flag to show reasoning blocks in run command (#12013) 2026-02-03 20:49:15 +00:00
MartinWie
82dd4b6908 fix: always fall back to native clipboard after OSC52 (#11994) 2026-02-03 20:53:04 +01:00
opencode-agent[bot]
185858749b chore: generate 2026-02-03 19:10:44 +00:00
Cloudyan
39a504773c fix: provider headers from config not applied to fetch requests (#11788) 2026-02-03 13:09:07 -06:00
Aiden Cline
b7b734f51f fix: ensure mcp tools are sanitized (#11984) 2026-02-03 12:22:00 -06:00
Adam
dcff5b6596 fix(app): command palette placeholder text 2026-02-03 11:58:58 -06:00
Adam
4f7da2b757 fix(app): model selector truncating too soon 2026-02-03 11:51:30 -06:00
Alex
60e616ec81 feat: Add .slnx to C#/F# LSP root detection (#11928) 2026-02-03 11:46:04 -06:00
Adam
416964acd0 chore: gitignore codex 2026-02-03 11:44:03 -06:00
Adam
017ad2c7f7 fix(app): spacing 2026-02-03 10:27:53 -06:00
Adam
e33eb1b058 chore: cleanup 2026-02-03 10:27:53 -06:00
Aiden Cline
857b8a4b56 test: add test for new skill improvements (#11969) 2026-02-03 16:05:10 +00:00
Aiden Cline
3975329629 feat: improve skills, better prompting, fix permission asks after invoking skills, ensure agent knows where scripts/resources are (#11737) 2026-02-03 09:58:31 -06:00
neavo
54e14c1a17 fix: exclude k2p5 from reasoning variants (#11918) 2026-02-03 09:26:37 -06:00
Muhammad Mugni Hadi
3741516fe3 fix: handle nested array items for Gemini schema validation (#11952) 2026-02-03 09:24:52 -06:00
Daniel Polito
76381f33d5 feat(desktop): Allow empty prompt with review comments (#11953) 2026-02-03 09:06:02 -06:00
Luiz Guilherme D'Abruzzo Pereira
95d0d476e3 docs: add --mdns-domain flag documentation (#11933) 2026-02-03 07:26:42 -06:00
Adam
7508839b70 fix(app): terminal serialization bug 2026-02-03 07:03:34 -06:00
Adam
e88cbefabe fix(app): terminal serialization bug 2026-02-03 07:03:34 -06:00
Adam
a38bae684f chore(app): don't forceMount tooltips 2026-02-03 07:01:12 -06:00
Rahul A Mistry
08671e3155 fix(app): session tabs to open the previous opened (#11914) 2026-02-03 06:19:56 -06:00
Rahul A Mistry
0d557721cf fix(app): edit project dialog icon on hover (#11921) 2026-02-03 06:19:07 -06:00
Adam
e709808b32 fix(app): move session search to command palette 2026-02-03 05:19:43 -06:00
Devin Griffin
0d22068c90 fix(app): custom providers overflow (#11252)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-02-03 16:17:13 +08:00
Filip
d116c227e0 fix(core): plugins are always reinstalled (#9675) 2026-02-03 00:47:52 -06:00
opencode-agent[bot]
3f07dffbb0 chore: generate 2026-02-03 05:19:12 +00:00
Frank
801e4a8a9d wip: zen 2026-02-03 00:17:06 -05:00
Kiyoung Chang
3adeed8f97 fix(provider): strip properties/required from non-object types in Gemini schema (#11888) 2026-02-02 23:13:24 -06:00
Dax
1275c71a63 cli: make run non-interactive (#11814) 2026-02-02 23:35:23 -05:00
Aiden Cline
ca8c23dd71 ci: add license to npm (#11883) 2026-02-03 03:44:30 +00:00
opencode
acc2bf5db9 release: v1.1.49 2026-02-03 03:44:18 +00:00
Filip
96fbc30945 fix(app): drag region for native controls (#11854) 2026-02-02 18:20:05 -06:00
Alex Yaroshuk
ba545ba9b3 fix(app): session scroll bar color (#11857) 2026-02-02 17:01:47 -06:00
Aiden Cline
188cc24bfc chore: cleanup external_dir perm logic (#11845) 2026-02-02 16:43:55 -06:00
opencode-agent[bot]
5e3162b7f4 chore: generate 2026-02-02 22:30:23 +00:00
Joseph Campuzano
f9aa209131 fix: fixes issue where the autocomplete tui dialog flickers while typing (#11641) 2026-02-02 16:29:43 -06:00
Aiden Cline
f86f654cda chore: rm dead code (#11849) 2026-02-02 16:22:03 -06:00
Filip
aadd2e13d7 fix(app): prompt input overflow issue (#11840) 2026-02-02 22:02:56 +00:00
Adam
531357b40c fix(app): sidebar losing projects on collapse 2026-02-02 15:54:04 -06:00
Aiden Cline
aa6b552c39 Revert pr that was mistakenly merged (#11844) 2026-02-02 15:28:02 -06:00
opencode-agent[bot]
a3f1918489 chore: generate 2026-02-02 20:53:36 +00:00
Luiz Guilherme D'Abruzzo Pereira
a9fca05d8b feat(server): add --mdns-domain flag to customize mDNS hostname (#11796) 2026-02-02 14:52:32 -06:00
Alex Yaroshuk
824165eb79 feat(app): add workspace toggle command to command palette and prompt input (#11810) 2026-02-02 14:36:00 -06:00
opencode-agent[bot]
562c9d76d9 chore: generate 2026-02-02 20:27:15 +00:00
Adam
c002ca03ba feat(app): search through sessions 2026-02-02 14:26:09 -06:00
Adam
befb5d54fb chore: cleanup 2026-02-02 14:24:25 -06:00
Adam
69f5f657f2 chore: cleanup 2026-02-02 14:24:25 -06:00
Adam
0405b425f5 feat(app): file search 2026-02-02 14:24:24 -06:00
Adam
70cf609ce9 Revert "feat(ui): Select, dropdown, popover styles & transitions (#11675)"
This reverts commit 377bf7ff21.
2026-02-02 14:24:24 -06:00
Adam
2f76b49df3 Revert "feat(ui): Smooth fading out on scroll, style fixes (#11683)"
This reverts commit e445dc0746.
2026-02-02 14:24:24 -06:00
Adam
dfd5f38408 fix(app): icon sizes 2026-02-02 14:24:24 -06:00
Adam
3b93e8d95c fix(app): added/deleted file status now correctly calculated 2026-02-02 14:24:23 -06:00
Adam
23631a9393 fix(app): navigate to last project on open 2026-02-02 14:24:23 -06:00
Adam
f1e0c31b8f fix(app): button heights 2026-02-02 14:24:23 -06:00
Adam
30a25e4edc fix(app): user messages not rendering consistently 2026-02-02 14:24:23 -06:00
Adam
ea1aba4192 feat(app): project context menu on right-click 2026-02-02 14:24:22 -06:00
Filip
b9aad20be6 fix(app): open project search (#11783) 2026-02-02 12:15:53 -06:00
Pablo P Varela
965f32ad63 fix(tui): respect terminal transparency in system theme (#8467) 2026-02-02 11:36:47 -06:00
opencode-agent[bot]
cf828fff85 chore: update nix node_modules hashes 2026-02-02 16:56:31 +00:00
Vladimir Glafirov
cf8b033be1 feat(provider): add User-Agent header for GitLab AI Gateway requests (#11818) 2026-02-02 10:41:02 -06:00
Goni Zahavy
1bd5dc5382 ci: add ratelimits handling for close-stale-prs.yml (#11578) 2026-02-02 10:13:48 -06:00
Dax Raad
06d63ca54c ci: use native ARM runner for faster Linux ARM builds
Switch from cross-compilation on x86_64 to native ARM runner, which improves build speed and reliability for Linux ARM binary distribution.
2026-02-02 10:06:21 -05:00
Dax Raad
423778c93a ci: reduce aarch64 build runner to 4 vcpu to lower infrastructure costs 2026-02-02 09:44:19 -05:00
Dax Raad
8de9e47a5b ci 2026-02-02 09:37:39 -05:00
Dax Raad
d63ed3bbe3 ci 2026-02-02 09:37:37 -05:00
Dax
4369d79636 tui: truncate session title in exit banner (#11797) 2026-02-02 14:30:10 +00:00
Rahul A Mistry
3408f1a6ae feat(app): add tab close keybind (#11780) 2026-02-02 06:50:06 -06:00
opencode-agent[bot]
34c58af796 chore: generate 2026-02-02 12:07:56 +00:00
OpeOginni
37979ea44f feat(app): enhance responsive design with additional breakpoints for larger screen layout adjustments (#10459)
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-02-02 06:06:45 -06:00
OpeOginni
50b5168c16 fix(desktop): added inverted svg for steps expanded for nice UX (#10462)
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-02-02 06:03:55 -06:00
opencode-agent[bot]
6b17645f2e chore: generate 2026-02-02 12:02:35 +00:00
Ondřej Súkup
52006c2fd9 feat(opencode): ormolu code formatter for haskell (#10274) 2026-02-02 06:01:49 -06:00
Adam
26197ec95b chore: update website stats 2026-02-02 05:42:55 -06:00
Sam Huckaby
43bb389e35 Fix(app): the Vesper theme's light mode (#9892) 2026-02-02 11:30:44 +00:00
Lucio Delelis
985090ef3c fix(ui): adjusts alignment of elements to prevent incomplete scroll (#11649) 2026-02-02 05:20:30 -06:00
Brendan Allan
52eb8a7a8c feat(app): unread session navigation keybinds (#11750) 2026-02-02 05:05:47 -06:00
opencode-agent[bot]
1cabeb00d0 chore: generate 2026-02-02 10:53:46 +00:00
Brendan Allan
9564c1d6be desktop: fix rust build + bindings formatting 2026-02-02 18:52:57 +08:00
Brendan Allan
1832eeffc9 fix(desktop): remove unnecessary setTimeout 2026-02-02 18:19:49 +08:00
Brendan Allan
e6d8315e29 fix(desktop): throttle window state persistence (#11746) 2026-02-02 09:54:57 +00:00
opencode-agent[bot]
784a17f7b3 chore: generate 2026-02-02 07:58:52 +00:00
Brendan Allan
04aef44fc3 chore(desktop): integrate tauri-specta (#11740) 2026-02-02 07:58:08 +00:00
Brendan Allan
c02dd067b2 fix(desktop): keep mac titlebar stable under zoom (#11747) 2026-02-02 07:54:25 +00:00
opencode-agent[bot]
141fdef588 chore: update nix node_modules hashes 2026-02-02 07:21:25 +00:00
Sebastian
3982c7d99a Use opentui OSC52 clipboard, again (#11744) 2026-02-02 08:12:50 +01:00
mohammad
76745d0594 fix(desktop): kill zombie server process on startup timeout (#11602)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-02-02 13:30:46 +08:00
Frank
4850ecc419 zen: rate limit (#11735) 2026-02-02 00:29:52 -05:00
Jigar
43354eeabd fix: convert system message content to string for Copilot provider (#11600)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:28:28 -06:00
Dax
7a9290dc9b tui: show exit message banner (#11733) 2026-02-02 00:13:47 -05:00
Dax Raad
cfbe9d329f Revert "Use opentui OSC52 clipboard (#11718)"
This reverts commit 8e985e0a75.
2026-02-02 00:13:02 -05:00
OpeOginni
f02499fa44 fix(opencode): give OPENCODE_CONFIG_CONTENT proper priority for setting config based on docs (#11670) 2026-02-01 23:11:25 -06:00
Mathias Beugnon
bd9d7b3221 fix: session title generation with OpenAI models. (#11678) 2026-02-01 23:10:53 -06:00
182 changed files with 5808 additions and 2727 deletions

1
.github/CODEOWNERS vendored
View File

@@ -1,4 +1,5 @@
# web + desktop packages
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin

View File

@@ -18,6 +18,7 @@ permissions:
jobs:
close-stale-prs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Close inactive PRs
uses: actions/github-script@v8
@@ -25,6 +26,15 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const DAYS_INACTIVE = 60
const MAX_RETRIES = 3
// Adaptive delay: fast for small batches, slower for large to respect
// GitHub's 80 content-generating requests/minute limit
const SMALL_BATCH_THRESHOLD = 10
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
const startTime = Date.now()
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
const { owner, repo } = context.repo
const dryRun = context.payload.inputs?.dryRun === "true"
@@ -32,6 +42,42 @@ jobs:
core.info(`Dry run mode: ${dryRun}`)
core.info(`Cutoff date: ${cutoff.toISOString()}`)
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function withRetry(fn, description = 'API call') {
let lastError
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await fn()
return result
} catch (error) {
lastError = error
const isRateLimited = error.status === 403 &&
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
if (!isRateLimited) {
throw error
}
// Parse retry-after header, default to 60 seconds
const retryAfter = error.response?.headers?.['retry-after']
? parseInt(error.response.headers['retry-after'])
: 60
// Exponential backoff: retryAfter * 2^attempt
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
await sleep(backoffMs)
}
}
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
throw lastError
}
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
@@ -73,17 +119,27 @@ jobs:
const allPrs = []
let cursor = null
let hasNextPage = true
let pageCount = 0
while (hasNextPage) {
const result = await github.graphql(query, {
owner,
repo,
cursor,
})
pageCount++
core.info(`Fetching page ${pageCount} of open PRs...`)
const result = await withRetry(
() => github.graphql(query, { owner, repo, cursor }),
`GraphQL page ${pageCount}`
)
allPrs.push(...result.repository.pullRequests.nodes)
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
cursor = result.repository.pullRequests.pageInfo.endCursor
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
// Delay between pagination requests (use small batch delay for reads)
if (hasNextPage) {
await sleep(SMALL_BATCH_DELAY_MS)
}
}
core.info(`Found ${allPrs.length} open pull requests`)
@@ -114,28 +170,66 @@ jobs:
core.info(`Found ${stalePrs.length} stale pull requests`)
// ============================================
// Close stale PRs
// ============================================
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
? LARGE_BATCH_DELAY_MS
: SMALL_BATCH_DELAY_MS
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
let closedCount = 0
let skippedCount = 0
for (const pr of stalePrs) {
const issue_number = pr.number
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
if (dryRun) {
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
continue
}
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: closeComment,
})
try {
// Add comment
await withRetry(
() => github.rest.issues.createComment({
owner,
repo,
issue_number,
body: closeComment,
}),
`Comment on PR #${issue_number}`
)
await github.rest.pulls.update({
owner,
repo,
pull_number: issue_number,
state: "closed",
})
// Close PR
await withRetry(
() => github.rest.pulls.update({
owner,
repo,
pull_number: issue_number,
state: "closed",
}),
`Close PR #${issue_number}`
)
core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
closedCount++
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
// Delay before processing next PR
await sleep(requestDelayMs)
} catch (error) {
skippedCount++
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
}
}
const elapsed = Math.round((Date.now() - startTime) / 1000)
core.info(`\n========== Summary ==========`)
core.info(`Total open PRs found: ${allPrs.length}`)
core.info(`Stale PRs identified: ${stalePrs.length}`)
core.info(`PRs closed: ${closedCount}`)
core.info(`PRs skipped (errors): ${skippedCount}`)
core.info(`Elapsed time: ${elapsed}s`)
core.info(`=============================`)

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ node_modules
.env
.idea
.vscode
.codex
*~
playground
tmp

View File

@@ -1,6 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
// "plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
@@ -9,12 +8,7 @@
"options": {},
},
},
"mcp": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
},
},
"mcp": {},
"tools": {
"github-triage": false,
"github-pr-search": false,

View File

@@ -1 +1,2 @@
sst-env.d.ts
sst-env.d.ts
desktop/src/bindings.ts

View File

@@ -82,7 +82,7 @@ The install script respects the following priority order for the installation pa
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
3. `$HOME/bin` - Standard user binary directory (if exists or can be created)
3. `$HOME/bin` - Standard user binary directory (if it exists or can be created)
4. `$HOME/.opencode/bin` - Default fallback
```bash
@@ -95,20 +95,20 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
OpenCode includes two built-in agents you can switch between with the `Tab` key.
- **build** - Default, full access agent for development work
- **build** - Default, full-access agent for development work
- **plan** - Read-only agent for analysis and code exploration
- Denies file edits by default
- Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes
Also, included is a **general** subagent for complex searches and multistep tasks.
Also included is a **general** subagent for complex searches and multistep tasks.
This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents).
### Documentation
For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).
For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs).
### Contributing
@@ -116,7 +116,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
### Building on OpenCode
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
### FAQ
@@ -125,10 +125,10 @@ If you are working on a project that's related to OpenCode and is using "opencod
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.
- Out of the box LSP support
- 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.
- Out-of-the-box LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.
---

View File

@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -213,7 +213,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -242,7 +242,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -258,7 +258,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.48",
"version": "1.1.51",
"bin": {
"opencode": "./bin/opencode",
},
@@ -286,7 +286,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.56",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.3.1",
"@gitlab/gitlab-ai-provider": "3.4.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -298,8 +298,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.76",
"@opentui/solid": "0.1.76",
"@opentui/core": "0.1.77",
"@opentui/solid": "0.1.77",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -307,8 +307,9 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.4",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -362,7 +363,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -382,7 +383,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.48",
"version": "1.1.51",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -393,7 +394,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -406,7 +407,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -448,7 +449,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"zod": "catalog:",
},
@@ -459,7 +460,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.48",
"version": "1.1.51",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -569,8 +570,16 @@
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4d5EKu0OW7Gf5WFpGo4ixn0iWEwA+GpteqUjEznWGmi7qdLE5zdkbRik5B1HrDDiw5P90yO51xBex/Fp50JcVA=="],
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WWOz5Kj+5fVe94h7WeReqjUOVtAquDE2kM575FUc8CsVxH2tRfA5cLa8nu3bknSezsKt3i67YM6mvCRxiXCkWA=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
@@ -925,7 +934,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1227,21 +1236,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.76", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.76", "@opentui/core-darwin-x64": "0.1.76", "@opentui/core-linux-arm64": "0.1.76", "@opentui/core-linux-x64": "0.1.76", "@opentui/core-win32-arm64": "0.1.76", "@opentui/core-win32-x64": "0.1.76", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Y4f4KH6Mbj0J6+MorcvtHSeT+Lbs3YDPEQcTRTWsPOqWz3A0F5/+OPtZKho1EtLWQqJflCWdf/JQj5A3We3qRg=="],
"@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.76", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aRYNOPRKL6URovSPhRvXtBV7SqdmR7s6hmEBSdXiYo39AozTcvKviF8gJWXQATcKDEcOtRir6TsASzDq5Coheg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.76", "", { "os": "darwin", "cpu": "x64" }, "sha512-KFaRvVQ0Wr1PgaexUkF3KYt41pYmxGJW3otENeE6WDa/nXe2AElibPFRjqSEH54YrY5Q84SDI77/wGP4LZ/Wyg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.76", "", { "os": "linux", "cpu": "arm64" }, "sha512-s7v+GDwavfieZg8xZV4V07fXFrHfFq4UZ2JpYFDUgNs9vFp+++WUjh3pfbfE+2ldbhcG2iOtuiV9aG1tVCbTEg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.76", "", { "os": "linux", "cpu": "x64" }, "sha512-ugwuHpmvdKRHXKVsrC3zRYY6bg2JxVCzAQ1NOiWRLq3N3N4ha6BHAkHMCeHgR/ZI4R8MSRB6vtJRVI1F9VHxjA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.76", "", { "os": "win32", "cpu": "arm64" }, "sha512-wjpRWrerPItb5E1fP4SAcNMxQp1yEukbgvP4Azip836/ixxbghL6y0P57Ya/rv7QYLrkNZXoQ+tr9oXhPH5BVA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.76", "", { "os": "win32", "cpu": "x64" }, "sha512-2YjtZJdd3iO+SY9NKocE4/Pm9VolzAthUOXjpK4Pv5pnR9hBpPvX7FFSXJTfASj7y2j1tATWrlQLocZCFP/oMA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
"@opentui/solid": ["@opentui/solid@0.1.76", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.76", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-PiD62FGoPoVLFpY4g08i4UYlx4sGR2OmHUPj6CuZZwy2UJD4fKn1RYV+kAPHfUW4qN/88I1k/w/Dniz1WvXrAQ=="],
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1949,6 +1958,8 @@
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -2097,7 +2108,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
@@ -3989,6 +4000,8 @@
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
@@ -4273,6 +4286,14 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -4805,6 +4826,14 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-aRFzPzgu32XgNSk8S2z4glTlgHqEmOLZHlBQSIYIMvY=",
"aarch64-linux": "sha256-aCZLkmRrCa0bli0jgsaLcC5GlZdjQPbb6xD6Fc03eX8=",
"aarch64-darwin": "sha256-oZOOR6k8MmabNVDQNY5ywR06rRycdnXZL+gUucKSQ+g=",
"x86_64-darwin": "sha256-LXIcLnjn+1eTFWIsQ9W0U2orGm59P/L470O0KFFkRHg="
"x86_64-linux": "sha256-9XlAYCNdBhw8NmfJoYNjvQYhSn02rFhWvbJtlOnnCjc=",
"aarch64-linux": "sha256-Mdz3gAy8auN7mhMHRaWyH/exHGO9eYDyUMQKqscg6Xc=",
"aarch64-darwin": "sha256-NDB6+NVZ4+9+Yds/cjEGQAn9Tl/LRuEjEH6wV5dTdVg=",
"x86_64-darwin": "sha256-LGJ5TJYgyK8Vn0BliEeJdoblcubj5ZIjvJoUtdVXfvU="
}
}

View File

@@ -1,5 +1,5 @@
import { test as base, expect } from "@playwright/test"
import { seedProjects } from "./actions"
import { test as base, expect, type Page } from "@playwright/test"
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -8,6 +8,14 @@ export const settingsKey = "settings.v3"
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(
callback: (project: {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
}
type WorkerFixtures = {
@@ -33,17 +41,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
await seedProjects(page, { directory })
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
)
})
await seedStorage(page, { directory })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
@@ -51,6 +49,39 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
}
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const directory = await createTestProject()
const slug = dirSlug(directory)
await seedStorage(page, { directory, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
}
try {
await gotoSession()
return await callback({ directory, slug, gotoSession })
} finally {
await cleanupTestProject(directory)
}
})
},
})
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
)
})
}
export { expect }

View File

@@ -1,52 +1,53 @@
import { test, expect } from "../fixtures"
import { openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
await gotoSession()
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await openSidebar(page)
await withProject(async () => {
await openSidebar(page)
const open = async () => {
const header = page.locator(".group\\/project").first()
await header.hover()
const trigger = header.getByRole("button", { name: "More options" }).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
await expect(editItem).toBeVisible()
await editItem.click({ force: true })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog
}
const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}`
const dialog = await open()
const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup)
await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0)
const open = async () => {
const header = page.locator(".group\\/project").first()
await header.hover()
const trigger = header.getByRole("button", { name: "More options" }).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
await expect(header).toContainText(name)
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
await expect(editItem).toBeVisible()
await editItem.click({ force: true })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog
}
const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}`
const dialog = await open()
const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup)
await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0)
const header = page.locator(".group\\/project").first()
await expect(header).toContainText(name)
const reopened = await open()
await expect(reopened.getByLabel("Name")).toHaveValue(name)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0)
const reopened = await open()
await expect(reopened.getByLabel("Name")).toHaveValue(name)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0)
})
})

View File

@@ -1,69 +1,73 @@
import { test, expect } from "../fixtures"
import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => {
test("can close a project via hover card close button", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
await seedProjects(page, { directory, extra: [other] })
try {
await gotoSession()
await withProject(
async () => {
await openSidebar(page)
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.hover()
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.hover()
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
await expect(close).toBeVisible()
await close.click()
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
await expect(close).toBeVisible()
await close.click()
await expect(otherButton).toHaveCount(0)
await expect(otherButton).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => {
test("can close a project via project header more options menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherName = other.split("/").pop() ?? other
const otherSlug = dirSlug(other)
await seedProjects(page, { directory, extra: [other] })
try {
await gotoSession()
await withProject(
async () => {
await openSidebar(page)
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const header = page
.locator(".group\\/project")
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
.first()
await expect(header).toContainText(otherName)
const header = page
.locator(".group\\/project")
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
.first()
await expect(header).toContainText(otherName)
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
await expect(trigger).toHaveCount(1)
await trigger.focus()
await page.keyboard.press("Enter")
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
await expect(trigger).toHaveCount(1)
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible({ timeout: 10_000 })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible({ timeout: 10_000 })
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect(otherButton).toHaveCount(0)
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect(otherButton).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}

View File

@@ -1,33 +1,34 @@
import { test, expect } from "../fixtures"
import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
import { defocus, createTestProject, cleanupTestProject } from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => {
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
await seedProjects(page, { directory, extra: [other] })
try {
await gotoSession()
await withProject(
async ({ directory }) => {
await defocus(page)
await defocus(page)
const currentSlug = dirSlug(directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
const currentSlug = dirSlug(directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
await expect(currentButton).toBeVisible()
await currentButton.click()
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
await expect(currentButton).toBeVisible()
await currentButton.click()
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}

View File

@@ -10,33 +10,20 @@ import {
cleanupTestProject,
clickMenuItem,
confirmDialog,
createTestProject,
openSidebar,
openWorkspaceMenu,
seedProjects,
setWorkspacesEnabled,
} from "../actions"
import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
import { dirSlug } from "../utils"
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) {
const project = await createTestProject()
const rootSlug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
await gotoSession()
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
const target = page.locator(projectSwitchSelector(rootSlug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
@@ -70,25 +57,13 @@ async function setupWorkspaceTest(page: Page, directory: string, gotoSession: ()
)
.toBe(true)
return { project, rootSlug, slug, directory: dir }
return { rootSlug, slug, directory: dir }
}
test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => {
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const project = await createTestProject()
const slug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
try {
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(slug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await withProject(async ({ slug }) => {
await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
@@ -101,27 +76,13 @@ test("can enable and disable workspaces from project menu", async ({ page, direc
await setWorkspacesEnabled(page, slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
} finally {
await cleanupTestProject(project)
}
})
})
test("can create a workspace", async ({ page, directory, gotoSession }) => {
test("can create a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const project = await createTestProject()
const slug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
try {
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(slug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await withProject(async ({ slug }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
@@ -162,17 +123,15 @@ test("can create a workspace", async ({ page, directory, gotoSession }) => {
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
await cleanupTestProject(workspaceDir)
} finally {
await cleanupTestProject(project)
}
})
})
test("can rename a workspace", async ({ page, directory, gotoSession }) => {
test("can rename a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession)
await withProject(async (project) => {
const { slug } = await setupWorkspaceTest(page, project)
try {
const rename = `e2e workspace ${Date.now()}`
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Rename$/i, { force: true })
@@ -186,17 +145,15 @@ test("can rename a workspace", async ({ page, directory, gotoSession }) => {
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
} finally {
await cleanupTestProject(project)
}
})
})
test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
test("can reset a workspace", async ({ page, sdk, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession)
await withProject(async (project) => {
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
try {
const readme = path.join(createdDir, "README.md")
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8")
@@ -250,17 +207,15 @@ test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
.catch(() => false)
})
.toBe(false)
} finally {
await cleanupTestProject(project)
}
})
})
test("can delete a workspace", async ({ page, directory, gotoSession }) => {
test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession)
await withProject(async (project) => {
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
try {
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
@@ -268,124 +223,111 @@ test("can delete a workspace", async ({ page, directory, gotoSession }) => {
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
} finally {
await cleanupTestProject(project)
}
})
})
test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => {
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
const workspaces = [] as { directory: string; slug: string }[]
const project = await createTestProject()
const rootSlug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const workspaces = [] as { directory: string; slug: string }[]
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => {
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
const drag = async (from: string, to: string) => {
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
await src.scrollIntoViewIfNeeded()
await dst.scrollIntoViewIfNeeded()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
await page.mouse.down()
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
try {
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(rootSlug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
const waitReady = async (slug: string) => {
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 45_000 },
{ timeout: 60_000 },
)
.toBe(true)
}
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })
const drag = async (from: string, to: string) => {
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
await src.scrollIntoViewIfNeeded()
await dst.scrollIntoViewIfNeeded()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
await page.mouse.down()
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
try {
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })
await openSidebar(page)
}
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
}
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
await cleanupTestProject(project)
}
})
})

View File

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

View File

@@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
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 win = process.platform === "win32"
export default defineConfig({
testDir: "./e2e",
@@ -15,8 +14,7 @@ export default defineConfig({
expect: {
timeout: 10_000,
},
fullyParallel: !win,
workers: win ? 1 : undefined,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],

View File

@@ -241,19 +241,19 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = ""
if (this._nullCellCount > 0) {
const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1)
const wrapped = !!nextLine?.isWrapped
if (this._nullCellCount > 0 && wrapped) {
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
if (!isLastRow) {
const nextLine = this._buffer.getLine(row + 1)
this._nullCellCount = 0
if (!nextLine?.isWrapped) {
rowSeparator = "\r\n"
this._lastCursorRow = row + 1
this._lastCursorCol = 0
}
if (!isLastRow && !wrapped) {
rowSeparator = "\r\n"
this._lastCursorRow = row + 1
this._lastCursorCol = 0
}
this._allRows[this._rowIndex] = this._currentRow
@@ -389,7 +389,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
const styleChanged = sgrSeq.length > 0
if (styleChanged) {
if (this._nullCellCount > 0) {
@@ -442,12 +442,24 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
}
if (!excludeFinalCursorPosition) {
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
const cursorCol = this._buffer.cursorX + 1
content += `\u001b[${cursorRow};${cursorCol}H`
}
if (excludeFinalCursorPosition) return content
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
const cursorCol = this._buffer.cursorX + 1
content += `\u001b[${cursorRow};${cursorCol}H`
const line = this._buffer.getLine(absoluteCursorRow)
const cell = line?.getCell(this._buffer.cursorX)
const style = (() => {
if (!cell) return this._buffer.getNullCell()
if (cell.getWidth() !== 0) return cell
if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell
return cell
})()
const sgrSeq = this._diffStyle(style, this._cursorStyle)
if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m`
return content
}

View File

@@ -158,22 +158,22 @@ export function DialogEditProject(props: { project: LocalProject }) {
</Show>
</div>
<div
class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": store.iconHover && !store.iconUrl,
"opacity-0": !(store.iconHover && !store.iconUrl),
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-invert-base" />
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
<div
class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": store.iconHover && !!store.iconUrl,
"opacity-0": !(store.iconHover && !!store.iconUrl),
}}
>
<Icon name="trash" size="large" class="text-icon-invert-base" />
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
</div>
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />

View File

@@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort"
import { createMemo } from "solid-js"
import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import type { ListRef } from "@opencode-ai/ui/list"
interface DialogSelectDirectoryProps {
title?: string
@@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps {
onSelect: (result: string | string[] | null) => void
}
type Row = {
absolute: string
search: string
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()
const home = createMemo(() => sync.data.path.home)
const [filter, setFilter] = createSignal("")
const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
const clean = (value: string) => {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
function normalize(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
@@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return ""
}
function display(path: string) {
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function display(path: string, input: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full) || full
}
function tildeOf(absolute: string) {
const full = trimTrailing(absolute)
const h = home()
if (!h) return full
if (!h) return ""
const hn = trimTrailing(h)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return full
return ""
}
function scoped(filter: string) {
function row(absolute: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search }
}
function scoped(value: string) {
const base = start()
if (!base) return
const raw = normalizeDriveRoot(filter.trim())
const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }
const h = home()
@@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}
const directories = async (filter: string) => {
const input = scoped(filter)
if (!input) return [] as string[]
const value = clean(filter)
const scopedInput = scoped(value)
if (!scopedInput) return [] as string[]
const raw = normalizeDriveRoot(filter.trim())
const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(input.path)
const query = normalizeDriveRoot(scopedInput.path)
if (!isPath) {
const results = await sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
const find = () =>
sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
return results.map((rel) => join(input.directory, rel)).slice(0, 50)
if (!isPath) {
const results = await find()
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
@@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const cap = 12
const branch = 4
let paths = [input.directory]
let paths = [scopedInput.directory]
for (const part of head) {
if (part === "..") {
paths = paths.map((p) => {
const v = trimTrailing(p)
if (v === "/") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
return v.slice(0, i)
})
paths = paths.map(parentOf)
continue
}
@@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
return Array.from(new Set(out)).slice(0, 50)
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/")
if (!expand || !tail) {
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
return items.slice(0, 50)
}
const needle = tail.toLowerCase()
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
const target = exact[0]
if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30)
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
}
const items = async (value: string) => {
const results = await directories(value)
return results.map(row)
}
function resolve(absolute: string) {
@@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
items={directories}
key={(x) => x}
items={items}
key={(x) => x.absolute}
filterKeys={["search"]}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(clean(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
if (!item) return
e.preventDefault()
e.stopPropagation()
const value = display(item.absolute, filter())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
if (!path) return
resolve(path)
resolve(path.absolute)
}}
>
{(absolute) => {
const path = display(absolute)
{(item) => {
const path = display(item.absolute, filter())
if (path === "~") {
return (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">~</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}
return (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: absolute, type: "directory" }} class="shrink-0 size-4" />
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>

View File

@@ -1,17 +1,22 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
import { createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
type EntryType = "command" | "file"
type EntryType = "command" | "file" | "session"
type Entry = {
id: string
@@ -22,6 +27,9 @@ type Entry = {
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
}
type DialogSelectFileMode = "all" | "files"
@@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const file = useFile()
const dialog = useDialog()
const params = useParams()
const navigate = useNavigate()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const filesOnly = () => props.mode === "files"
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
@@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
path,
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => globalSync.data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = globalSync.child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
const sessionItem = (input: {
directory: string
id: string
title: string
description: string
archived?: number
}): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category: language.t("command.category.session"),
directory: input.directory,
sessionID: input.id,
archived: input.archived,
})
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
@@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return out
}
const sessionToken = { value: 0 }
let sessionInflight: Promise<Entry[]> | undefined
let sessionAll: Entry[] | undefined
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
sessionToken.value += 1
sessionInflight = undefined
sessionAll = undefined
return [] as Entry[]
}
if (sessionAll) return sessionAll
if (sessionInflight) return sessionInflight
const current = sessionToken.value
const dirs = workspaces()
if (dirs.length === 0) return [] as Entry[]
sessionInflight = Promise.all(
dirs.map((directory) => {
const description = label(directory)
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
})),
)
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
}),
)
.then((results) => {
if (sessionToken.value !== current) return [] as Entry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(sessionItem)
sessionAll = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
sessionInflight = undefined
})
return sessionInflight
}
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
@@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const files = await file.searchFiles(query)
return files.map(fileItem)
}
const files = await file.searchFiles(query)
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const entries = files.map(fileItem)
return [...list(), ...entries]
return [...list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
@@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
open(item.path)
}
@@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
groupBy={(item) => item.category}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
>
{(item) => (
<Show
when={item.type === "command"}
<Switch
fallback={
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div>
}
>
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<Show when={item.description}>
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
<Match when={item.type === "command"}>
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<Show when={item.description}>
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
</Show>
</div>
</Show>
</Match>
<Match when={item.type === "session"}>
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
<Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
<div class="flex items-center gap-2 min-w-0">
<span
class="text-14-regular text-text-strong truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.title}
</span>
<Show when={item.description}>
<span
class="text-14-regular text-text-weak truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.description}
</span>
</Show>
</div>
</div>
</div>
</Match>
</Switch>
)}
</List>
</Dialog>

View File

@@ -54,7 +54,6 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
forceMount={false}
value={
<ModelTooltip
model={item}
@@ -90,10 +89,9 @@ const ModelList: Component<{
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
provider?: string
children?: JSX.Element | ((open: boolean) => JSX.Element)
children?: JSX.Element
triggerAs?: T
triggerProps?: ComponentProps<T>
gutter?: number
}) {
const [store, setStore] = createStore<{
open: boolean
@@ -176,14 +174,14 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
}}
modal={false}
placement="top-start"
gutter={props.gutter ?? 8}
gutter={8}
>
<Kobalte.Trigger
ref={(el) => setStore("trigger", el)}
as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)}
>
{typeof props.children === "function" ? props.children(store.open) : props.children}
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
@@ -215,7 +213,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
class="p-1"
action={
<div class="flex items-center gap-1">
<Tooltip placement="top" forceMount={false} value={language.t("command.provider.connect")}>
<Tooltip placement="top" value={language.t("command.provider.connect")}>
<IconButton
icon="plus-small"
variant="ghost"
@@ -225,7 +223,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
onClick={handleConnectProvider}
/>
</Tooltip>
<Tooltip placement="top" forceMount={false} value={language.t("dialog.model.manage")}>
<Tooltip placement="top" value={language.t("dialog.model.manage")}>
<IconButton
icon="sliders"
variant="ghost"

View File

@@ -130,10 +130,57 @@ export default function FileTree(props: {
const nodes = file.tree.children(props.path)
const current = filter()
if (!current) return nodes
return nodes.filter((node) => {
const parent = (path: string) => {
const idx = path.lastIndexOf("/")
if (idx === -1) return ""
return path.slice(0, idx)
}
const leaf = (path: string) => {
const idx = path.lastIndexOf("/")
return idx === -1 ? path : path.slice(idx + 1)
}
const out = nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
const seen = new Set(out.map((node) => node.path))
for (const dir of current.dirs) {
if (parent(dir) !== props.path) continue
if (seen.has(dir)) continue
out.push({
name: leaf(dir),
path: dir,
absolute: dir,
type: "directory",
ignored: false,
})
seen.add(dir)
}
for (const item of current.files) {
if (parent(item) !== props.path) continue
if (seen.has(item)) continue
out.push({
name: leaf(item),
path: item,
absolute: item,
type: "file",
ignored: false,
})
seen.add(item)
}
return out.toSorted((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
})
const Node = (
@@ -274,7 +321,6 @@ export default function FileTree(props: {
return (
<Tooltip
forceMount={false}
openDelay={2000}
placement="bottom-start"
class="w-full"

View File

@@ -32,9 +32,7 @@ import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
import { Button } from "@opencode-ai/ui/button"
import { CycleLabel } from "@opencode-ai/ui/cycle-label"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -44,7 +42,6 @@ import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
@@ -1135,7 +1132,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const images = imageAttachments().slice()
const mode = store.mode
if (text.trim().length === 0 && images.length === 0) {
if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) {
if (working()) abort()
return
}
@@ -1223,7 +1220,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
return undefined
})
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
if (session) {
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
}
if (!session) return
@@ -1257,7 +1257,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput()
client.session
.shell({
sessionID: session?.id || "",
sessionID: session.id,
agent,
model,
command: text,
@@ -1280,7 +1280,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput()
client.session
.command({
sessionID: session?.id || "",
sessionID: session.id,
command: commandName,
arguments: args.join(" "),
agent,
@@ -1436,13 +1436,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: session?.id || "",
sessionID: session.id,
messageID,
})) as unknown as Part[]
const optimisticMessage: Message = {
id: messageID,
sessionID: session?.id || "",
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent,
@@ -1453,9 +1453,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
const messages = draft.message[session?.id || ""]
const messages = draft.message[session.id]
if (!messages) {
draft.message[session?.id || ""] = [optimisticMessage]
draft.message[session.id] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@@ -1463,7 +1463,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draft.part[messageID] = optimisticParts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}),
)
return
@@ -1471,9 +1471,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
const messages = draft.message[session?.id || ""]
const messages = draft.message[session.id]
if (!messages) {
draft.message[session?.id || ""] = [optimisticMessage]
draft.message[session.id] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@@ -1481,7 +1481,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draft.part[messageID] = optimisticParts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}),
)
}
@@ -1490,7 +1490,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
const messages = draft.message[session?.id || ""]
const messages = draft.message[session.id]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
@@ -1503,7 +1503,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
const messages = draft.message[session?.id || ""]
const messages = draft.message[session.id]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
@@ -1524,15 +1524,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session?.id, { type: "busy" })
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session?.id, { type: "idle" })
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
for (const item of commentItems) {
@@ -1549,7 +1549,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreInput()
}
pending.set(session?.id || "", { abort: controller, cleanup })
pending.set(session.id, { abort: controller, cleanup })
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
@@ -1577,7 +1577,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(session?.id || "")
pending.delete(session.id)
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
@@ -1587,7 +1587,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
sessionID: session?.id || "",
sessionID: session.id,
agent,
model,
messageID,
@@ -1597,9 +1597,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
void send().catch((err) => {
pending.delete(session?.id || "")
if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session?.id, { type: "idle" })
pending.delete(session.id)
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1621,28 +1621,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
}
const currrentModelVariant = createMemo(() => {
const modelVariant = local.model.variant.current() ?? ""
return modelVariant === "xhigh"
? "xHigh"
: modelVariant.length > 0
? modelVariant[0].toUpperCase() + modelVariant.slice(1)
: "Default"
})
const reasoningPercentage = createMemo(() => {
const variants = local.model.variant.list()
const current = local.model.variant.current()
const totalEntries = variants.length + 1
if (totalEntries <= 2 || current === "Default") {
return 0
}
const currentIndex = current ? variants.indexOf(current) + 1 : 0
return ((currentIndex + 1) / totalEntries) * 100
}, [local.model.variant])
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popover}>
@@ -1695,7 +1673,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</>
}
>
<Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">
@{(item as { type: "agent"; name: string }).name}
</span>
@@ -1760,9 +1738,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
>
<Show when={store.dragging}>
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak">
<Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
<Icon name="photo" class="size-8" />
<span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
</div>
</div>
@@ -1801,7 +1779,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}>
@@ -1818,7 +1796,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
icon="close-small"
variant="ghost"
class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
@@ -1848,7 +1826,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
when={attachment.mime.startsWith("image/")}
fallback={
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<Icon name="folder" size="normal" class="size-6 text-text-base" />
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
>
@@ -1921,8 +1899,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Show>
</div>
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-2">
<div class="relative p-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
@@ -1934,6 +1912,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Match when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
@@ -1941,9 +1920,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class="capitalize"
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`}
valueClass="truncate"
variant="ghost"
gutter={12}
/>
</TooltipKeybind>
<Show
@@ -1951,66 +1930,68 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
fallback={
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
as="div"
variant="ghost"
class="px-2"
class="px-2 min-w-0 max-w-[240px]"
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
<MorphChevron
expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")}
/>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }} gutter={12}>
{(open) => (
<>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
<MorphChevron expanded={open} class="text-text-weak" />
</>
)}
<ModelSelectorPopover
triggerAs={Button}
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
<Show when={local.model.variant.list().length > 0}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Button
data-action="model-variant-cycle"
variant="ghost"
class="text-text-strong text-12-regular"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
onClick={() => local.model.variant.cycle()}
>
<Show when={local.model.variant.list().length > 1}>
<ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
</Show>
<CycleLabel value={currrentModelVariant()} />
{local.model.variant.current() ?? language.t("common.default")}
</Button>
</TooltipKeybind>
</Show>
<Show when={permission.permissionsEnabled() && params.id}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
@@ -2018,7 +1999,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex items-center justify-center": true,
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
@@ -2040,7 +2021,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Match>
</Switch>
</div>
<div class="flex items-center gap-1 absolute right-3 bottom-3">
<div class="flex items-center gap-1 shrink-0">
<input
ref={fileInputRef}
type="file"
@@ -2052,19 +2033,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1.5 mr-1.5">
<div class="flex items-center gap-1 mr-1">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
<Button
type="button"
variant="ghost"
size="small"
class="px-1"
class="size-6 px-1"
onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="photo" class="size-6 text-icon-base" />
<Icon name="photo" class="size-4.5" />
</Button>
</Tooltip>
</Show>
@@ -2083,7 +2063,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="normal" class="text-icon-base" />
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
@@ -2091,10 +2071,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<IconButton
type="submit"
disabled={!prompt.dirty() && !working()}
disabled={!prompt.dirty() && !working() && commentCount() === 0}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-5.5"
class="h-6 w-4.5"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>

View File

@@ -64,7 +64,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}
const circle = () => (
<div class="p-1">
<div class="flex items-center justify-center">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
</div>
)

View File

@@ -3,11 +3,12 @@ import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { getFilename } from "@opencode-ai/util/path"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
return (
@@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
return (
@@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
<Tabs.Trigger
value={props.tab}
closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom">
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
>
<IconButton
icon="close-small"
variant="ghost"
@@ -44,7 +50,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
</TooltipKeybind>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}

View File

@@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
@@ -131,12 +130,7 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [...SOUND_OPTIONS]
return (
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
@@ -232,7 +226,7 @@ export const SettingsGeneral: Component = () => {
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
>
{(option) => (
<span style={{ "font-family": monoFontFamily(option?.value) }}>
@@ -417,7 +411,7 @@ export const SettingsGeneral: Component = () => {
</div>
</div>
</div>
</ScrollFade>
</div>
)
}

View File

@@ -5,7 +5,6 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
@@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => {
})
return (
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
@@ -436,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
</div>
</Show>
</div>
</ScrollFade>
</div>
)
}

View File

@@ -9,7 +9,6 @@ import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
@@ -40,12 +39,7 @@ export const SettingsModels: Component = () => {
})
return (
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
@@ -131,6 +125,6 @@ export const SettingsModels: Component = () => {
</Show>
</Show>
</div>
</ScrollFade>
</div>
)
}

View File

@@ -12,7 +12,6 @@ import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderMeta = { source?: ProviderSource }
@@ -116,12 +115,7 @@ export const SettingsProviders: Component = () => {
}
return (
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
@@ -232,11 +226,11 @@ export const SettingsProviders: Component = () => {
</For>
<div
class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none"
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section"
>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-x-3">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">Custom provider</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
@@ -267,6 +261,6 @@ export const SettingsProviders: Component = () => {
</Button>
</div>
</div>
</ScrollFade>
</div>
)
}

View File

@@ -146,6 +146,7 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword

View File

@@ -24,6 +24,8 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1
const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
const [history, setHistory] = createStore({
stack: [] as string[],
@@ -134,7 +136,7 @@ export function Titlebar() {
return (
<header
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
data-tauri-drag-region
style={{ "min-height": minHeight() }}
>
<div
classList={{
@@ -142,10 +144,9 @@ export function Titlebar() {
"pl-2": !mac(),
}}
onMouseDown={drag}
data-tauri-drag-region
>
<Show when={mac()}>
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
@@ -219,13 +220,10 @@ export function Titlebar() {
</Tooltip>
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div>
<div
class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"
data-tauri-drag-region
>
<div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center">
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
</div>
@@ -235,9 +233,8 @@ export function Titlebar() {
"pr-6": !windows(),
}}
onMouseDown={drag}
data-tauri-drag-region
>
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" data-tauri-drag-region />
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
<Show when={windows()}>
<div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" />

View File

@@ -119,6 +119,8 @@ type ChildOptions = {
bootstrap?: boolean
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,
@@ -297,7 +299,7 @@ function createGlobalSync() {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return a.id.localeCompare(b.id)
return cmp(a.id, b.id)
}
function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
@@ -325,7 +327,7 @@ function createGlobalSync() {
const all = input
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => cmp(a.id, b.id))
const roots = all.filter((s) => !s.parentID)
const children = all.filter((s) => !!s.parentID)
@@ -342,7 +344,7 @@ function createGlobalSync() {
return sessionUpdatedAt(s) > cutoff
})
return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id))
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
}
function ensureChild(directory: string) {
@@ -457,7 +459,7 @@ function createGlobalSync() {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => cmp(a.id, b.id))
// Read the current limit at resolve-time so callers that bump the limit while
// a request is in-flight still get the expanded result.
@@ -559,7 +561,7 @@ function createGlobalSync() {
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
@@ -588,7 +590,7 @@ function createGlobalSync() {
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
@@ -986,7 +988,7 @@ function createGlobalSync() {
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => cmp(a.id, b.id))
setGlobalStore("project", projects)
}),
),

View File

@@ -33,6 +33,14 @@ type SessionTabs = {
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
pendingMessage?: string
pendingMessageAt?: number
}
type TabHandoff = {
dir: string
id: string
at: number
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -115,10 +123,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
handoff: {
tabs: undefined as TabHandoff | undefined,
},
}),
)
const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
@@ -411,6 +423,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return {
ready,
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
setStore("handoff", "tabs", undefined)
},
},
projects: {
list,
open(directory: string) {
@@ -536,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("mobileSidebar", "opened", (x) => !x)
},
},
pendingMessage: {
set(sessionKey: string, messageID: string) {
const at = Date.now()
touch(sessionKey)
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, {
scroll: {},
pendingMessage: messageID,
pendingMessageAt: at,
})
prune(meta.active ?? sessionKey)
return
}
setStore(
"sessionView",
sessionKey,
produce((draft) => {
draft.pendingMessage = messageID
draft.pendingMessageAt = at
}),
)
},
consume(sessionKey: string) {
const current = store.sessionView[sessionKey]
const message = current?.pendingMessage
const at = current?.pendingMessageAt
if (!message || !at) return
setStore(
"sessionView",
sessionKey,
produce((draft) => {
delete draft.pendingMessage
delete draft.pendingMessageAt
}),
)
if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
return message
},
},
view(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
@@ -682,12 +747,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!current) return
const all = current.all.filter((x) => x !== tab)
if (current.active !== tab) {
setStore("sessionTabs", session, "all", all)
return
}
const index = current.all.findIndex((f) => f === tab)
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
batch(() => {
setStore("sessionTabs", session, "all", all)
if (current.active !== tab) return
const index = current.all.findIndex((f) => f === tab)
const next = all[index - 1] ?? all[0]
setStore("sessionTabs", session, "active", next)
})
},

View File

@@ -1,5 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
export type Platform = {
/** Platform discriminator */
@@ -55,6 +56,9 @@ export type Platform = {
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
}
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

View File

@@ -9,6 +9,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client"
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@@ -59,7 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => cmp(a.id, b.id))
batch(() => {
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
@@ -69,7 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
"part",
message.info.id,
reconcile(
message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
@@ -129,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id))
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
}),
)
},
@@ -271,7 +273,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "فتح الإعدادات",
"command.session.previous": "الجلسة السابقة",
"command.session.next": "الجلسة التالية",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "أرشفة الجلسة",
"command.palette": "لوحة الأوامر",
@@ -42,7 +44,6 @@ export const dict = {
"command.session.new": "جلسة جديدة",
"command.file.open": "فتح ملف",
"command.file.open.description": "البحث في الملفات والأوامر",
"command.context.addSelection": "إضافة التحديد إلى السياق",
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
"command.terminal.toggle": "تبديل المحطة الطرفية",
@@ -68,6 +69,7 @@ export const dict = {
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل",
"command.session.undo": "تراجع",
"command.session.undo.description": "تراجع عن الرسالة الأخيرة",
"command.session.redo": "إعادة",
@@ -81,7 +83,7 @@ export const dict = {
"command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"palette.search.placeholder": "البحث في الملفات والأوامر",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.empty": "لا توجد نتائج",
"palette.group.commands": "الأوامر",
"palette.group.files": "الملفات",
@@ -346,6 +348,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
"toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة",
"toast.workspace.enabled.title": "تم تمكين مساحات العمل",
"toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي",
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
"toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي",
"toast.model.none.title": "لم يتم تحديد نموذج",
"toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة",

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Abrir configurações",
"command.session.previous": "Sessão anterior",
"command.session.next": "Próxima sessão",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Arquivar sessão",
"command.palette": "Paleta de comandos",
@@ -42,7 +44,6 @@ export const dict = {
"command.session.new": "Nova sessão",
"command.file.open": "Abrir arquivo",
"command.file.open.description": "Buscar arquivos e comandos",
"command.context.addSelection": "Adicionar seleção ao contexto",
"command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual",
"command.terminal.toggle": "Alternar terminal",
@@ -68,6 +69,7 @@ export const dict = {
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
"command.workspace.toggle": "Alternar espaços de trabalho",
"command.session.undo": "Desfazer",
"command.session.undo.description": "Desfazer a última mensagem",
"command.session.redo": "Refazer",
@@ -81,7 +83,7 @@ export const dict = {
"command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão",
"palette.search.placeholder": "Buscar arquivos e comandos",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.empty": "Nenhum resultado encontrado",
"palette.group.commands": "Comandos",
"palette.group.files": "Arquivos",
@@ -345,6 +347,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente",
"toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação",
"toast.workspace.enabled.title": "Espaços de trabalho ativados",
"toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral",
"toast.workspace.disabled.title": "Espaços de trabalho desativados",
"toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral",
"toast.model.none.title": "Nenhum modelo selecionado",
"toast.model.none.description": "Conecte um provedor para resumir esta sessão",

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Åbn indstillinger",
"command.session.previous": "Forrige session",
"command.session.next": "Næste session",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Arkivér session",
"command.palette": "Kommandopalette",
@@ -42,7 +44,6 @@ export const dict = {
"command.session.new": "Ny session",
"command.file.open": "Åbn fil",
"command.file.open.description": "Søg i filer og kommandoer",
"command.context.addSelection": "Tilføj markering til kontekst",
"command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil",
"command.terminal.toggle": "Skift terminal",
@@ -68,6 +69,7 @@ export const dict = {
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
"command.workspace.toggle": "Skift arbejdsområder",
"command.session.undo": "Fortryd",
"command.session.undo.description": "Fortryd den sidste besked",
"command.session.redo": "Omgør",
@@ -81,7 +83,7 @@ export const dict = {
"command.session.unshare": "Stop deling af session",
"command.session.unshare.description": "Stop med at dele denne session",
"palette.search.placeholder": "Søg i filer og kommandoer",
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
"palette.empty": "Ingen resultater fundet",
"palette.group.commands": "Kommandoer",
"palette.group.files": "Filer",
@@ -347,6 +349,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer",
"toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse",
"toast.workspace.enabled.title": "Arbejdsområder aktiveret",
"toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet",
"toast.workspace.disabled.title": "Arbejdsområder deaktiveret",
"toast.workspace.disabled.description": "Kun hoved-worktree vises i sidepanelet",
"toast.model.none.title": "Ingen model valgt",
"toast.model.none.description": "Forbind en udbyder for at opsummere denne session",

View File

@@ -32,6 +32,8 @@ export const dict = {
"command.settings.open": "Einstellungen öffnen",
"command.session.previous": "Vorherige Sitzung",
"command.session.next": "Nächste Sitzung",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Sitzung archivieren",
"command.palette": "Befehlspalette",
@@ -46,7 +48,6 @@ export const dict = {
"command.session.new": "Neue Sitzung",
"command.file.open": "Datei öffnen",
"command.file.open.description": "Dateien und Befehle durchsuchen",
"command.context.addSelection": "Auswahl zum Kontext hinzufügen",
"command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen",
"command.terminal.toggle": "Terminal umschalten",
@@ -72,6 +73,7 @@ export const dict = {
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
"command.workspace.toggle": "Arbeitsbereiche umschalten",
"command.session.undo": "Rückgängig",
"command.session.undo.description": "Letzte Nachricht rückgängig machen",
"command.session.redo": "Wiederherstellen",
@@ -85,7 +87,7 @@ export const dict = {
"command.session.unshare": "Teilen der Sitzung aufheben",
"command.session.unshare.description": "Teilen dieser Sitzung beenden",
"palette.search.placeholder": "Dateien und Befehle durchsuchen",
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
"palette.empty": "Keine Ergebnisse gefunden",
"palette.group.commands": "Befehle",
"palette.group.files": "Dateien",

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Open settings",
"command.session.previous": "Previous session",
"command.session.next": "Next session",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archive session",
"command.palette": "Command palette",
@@ -42,7 +44,7 @@ export const dict = {
"command.session.new": "New session",
"command.file.open": "Open file",
"command.file.open.description": "Search files and commands",
"command.tab.close": "Close tab",
"command.context.addSelection": "Add selection to context",
"command.context.addSelection.description": "Add selected lines from the current file",
"command.terminal.toggle": "Toggle terminal",
@@ -68,6 +70,8 @@ export const dict = {
"command.model.variant.cycle.description": "Switch to the next effort level",
"command.permissions.autoaccept.enable": "Auto-accept edits",
"command.permissions.autoaccept.disable": "Stop auto-accepting edits",
"command.workspace.toggle": "Toggle workspaces",
"command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar",
"command.session.undo": "Undo",
"command.session.undo.description": "Undo the last message",
"command.session.redo": "Redo",
@@ -81,7 +85,7 @@ export const dict = {
"command.session.unshare": "Unshare session",
"command.session.unshare.description": "Stop sharing this session",
"palette.search.placeholder": "Search files and commands",
"palette.search.placeholder": "Search files, commands, and sessions",
"palette.empty": "No results found",
"palette.group.commands": "Commands",
"palette.group.files": "Files",
@@ -347,6 +351,11 @@ export const dict = {
"toast.theme.title": "Theme switched",
"toast.scheme.title": "Color scheme",
"toast.workspace.enabled.title": "Workspaces enabled",
"toast.workspace.enabled.description": "Multiple worktrees are now shown in the sidebar",
"toast.workspace.disabled.title": "Workspaces disabled",
"toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar",
"toast.permissions.autoaccept.on.title": "Auto-accepting edits",
"toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved",
"toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits",

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Abrir ajustes",
"command.session.previous": "Sesión anterior",
"command.session.next": "Siguiente sesión",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archivar sesión",
"command.palette": "Paleta de comandos",
@@ -42,7 +44,6 @@ export const dict = {
"command.session.new": "Nueva sesión",
"command.file.open": "Abrir archivo",
"command.file.open.description": "Buscar archivos y comandos",
"command.context.addSelection": "Añadir selección al contexto",
"command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual",
"command.terminal.toggle": "Alternar terminal",
@@ -68,6 +69,7 @@ export const dict = {
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
"command.workspace.toggle": "Alternar espacios de trabajo",
"command.session.undo": "Deshacer",
"command.session.undo.description": "Deshacer el último mensaje",
"command.session.redo": "Rehacer",
@@ -81,7 +83,7 @@ export const dict = {
"command.session.unshare": "Dejar de compartir sesión",
"command.session.unshare.description": "Dejar de compartir esta sesión",
"palette.search.placeholder": "Buscar archivos y comandos",
"palette.search.placeholder": "Buscar archivos, comandos y sesiones",
"palette.empty": "No se encontraron resultados",
"palette.group.commands": "Comandos",
"palette.group.files": "Archivos",
@@ -348,6 +350,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente",
"toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación",
"toast.workspace.enabled.title": "Espacios de trabajo habilitados",
"toast.workspace.enabled.description": "Ahora se muestran varios worktrees en la barra lateral",
"toast.workspace.disabled.title": "Espacios de trabajo deshabilitados",
"toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral",
"toast.model.none.title": "Ningún modelo seleccionado",
"toast.model.none.description": "Conecta un proveedor para resumir esta sesión",

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Ouvrir les paramètres",
"command.session.previous": "Session précédente",
"command.session.next": "Session suivante",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archiver la session",
"command.palette": "Palette de commandes",
@@ -42,7 +44,6 @@ export const dict = {
"command.session.new": "Nouvelle session",
"command.file.open": "Ouvrir un fichier",
"command.file.open.description": "Rechercher des fichiers et des commandes",
"command.context.addSelection": "Ajouter la sélection au contexte",
"command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel",
"command.terminal.toggle": "Basculer le terminal",
@@ -68,6 +69,7 @@ export const dict = {
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
"command.workspace.toggle": "Basculer les espaces de travail",
"command.session.undo": "Annuler",
"command.session.undo.description": "Annuler le dernier message",
"command.session.redo": "Rétablir",
@@ -81,7 +83,7 @@ export const dict = {
"command.session.unshare": "Ne plus partager la session",
"command.session.unshare.description": "Arrêter de partager cette session",
"palette.search.placeholder": "Rechercher des fichiers et des commandes",
"palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
"palette.empty": "Aucun résultat trouvé",
"palette.group.commands": "Commandes",
"palette.group.files": "Fichiers",
@@ -350,6 +352,11 @@ export const dict = {
"toast.permissions.autoaccept.off.description":
"Les permissions de modification et d'écriture nécessiteront une approbation",
"toast.workspace.enabled.title": "Espaces de travail activés",
"toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale",
"toast.workspace.disabled.title": "Espaces de travail désactivés",
"toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale",
"toast.model.none.title": "Aucun modèle sélectionné",
"toast.model.none.description": "Connectez un fournisseur pour résumer cette session",

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "設定を開く",
"command.session.previous": "前のセッション",
"command.session.next": "次のセッション",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "セッションをアーカイブ",
"command.palette": "コマンドパレット",
@@ -42,7 +44,6 @@ export const dict = {
"command.session.new": "新しいセッション",
"command.file.open": "ファイルを開く",
"command.file.open.description": "ファイルとコマンドを検索",
"command.context.addSelection": "選択範囲をコンテキストに追加",
"command.context.addSelection.description": "現在のファイルから選択した行を追加",
"command.terminal.toggle": "ターミナルの切り替え",
@@ -68,6 +69,7 @@ export const dict = {
"command.model.variant.cycle.description": "次の思考レベルに切り替え",
"command.permissions.autoaccept.enable": "編集を自動承認",
"command.permissions.autoaccept.disable": "編集の自動承認を停止",
"command.workspace.toggle": "ワークスペースを切り替え",
"command.session.undo": "元に戻す",
"command.session.undo.description": "最後のメッセージを元に戻す",
"command.session.redo": "やり直す",
@@ -81,7 +83,7 @@ export const dict = {
"command.session.unshare": "セッションの共有を停止",
"command.session.unshare.description": "このセッションの共有を停止",
"palette.search.placeholder": "ファイルコマンドを検索",
"palette.search.placeholder": "ファイルコマンド、セッションを検索",
"palette.empty": "結果が見つかりません",
"palette.group.commands": "コマンド",
"palette.group.files": "ファイル",

View File

@@ -32,6 +32,8 @@ export const dict = {
"command.settings.open": "설정 열기",
"command.session.previous": "이전 세션",
"command.session.next": "다음 세션",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "세션 보관",
"command.palette": "명령 팔레트",
@@ -46,7 +48,6 @@ export const dict = {
"command.session.new": "새 세션",
"command.file.open": "파일 열기",
"command.file.open.description": "파일 및 명령어 검색",
"command.context.addSelection": "선택 영역을 컨텍스트에 추가",
"command.context.addSelection.description": "현재 파일에서 선택한 줄을 추가",
"command.terminal.toggle": "터미널 토글",
@@ -72,6 +73,7 @@ export const dict = {
"command.model.variant.cycle.description": "다음 생각 수준으로 전환",
"command.permissions.autoaccept.enable": "편집 자동 수락",
"command.permissions.autoaccept.disable": "편집 자동 수락 중지",
"command.workspace.toggle": "작업 공간 전환",
"command.session.undo": "실행 취소",
"command.session.undo.description": "마지막 메시지 실행 취소",
"command.session.redo": "다시 실행",
@@ -85,7 +87,7 @@ export const dict = {
"command.session.unshare": "세션 공유 중지",
"command.session.unshare.description": "이 세션 공유 중지",
"palette.search.placeholder": "파일 명령어 검색",
"palette.search.placeholder": "파일, 명령어 및 세션 검색",
"palette.empty": "결과 없음",
"palette.group.commands": "명령어",
"palette.group.files": "파일",
@@ -349,6 +351,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "편집 자동 수락 중지됨",
"toast.permissions.autoaccept.off.description": "편집 및 쓰기 권한 승인이 필요합니다",
"toast.workspace.enabled.title": "작업 공간 활성화됨",
"toast.workspace.enabled.description": "이제 사이드바에 여러 작업 트리가 표시됩니다",
"toast.workspace.disabled.title": "작업 공간 비활성화됨",
"toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다",
"toast.model.none.title": "선택된 모델 없음",
"toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요",

View File

@@ -31,6 +31,8 @@ export const dict = {
"command.settings.open": "Åpne innstillinger",
"command.session.previous": "Forrige sesjon",
"command.session.next": "Neste sesjon",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Arkiver sesjon",
"command.palette": "Kommandopalett",
@@ -45,7 +47,6 @@ export const dict = {
"command.session.new": "Ny sesjon",
"command.file.open": "Åpne fil",
"command.file.open.description": "Søk i filer og kommandoer",
"command.context.addSelection": "Legg til markering i kontekst",
"command.context.addSelection.description": "Legg til valgte linjer fra gjeldende fil",
"command.terminal.toggle": "Veksle terminal",
@@ -71,6 +72,7 @@ export const dict = {
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
"command.permissions.autoaccept.enable": "Godta endringer automatisk",
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
"command.workspace.toggle": "Veksle arbeidsområder",
"command.session.undo": "Angre",
"command.session.undo.description": "Angre siste melding",
"command.session.redo": "Gjør om",
@@ -84,7 +86,7 @@ export const dict = {
"command.session.unshare": "Slutt å dele sesjon",
"command.session.unshare.description": "Slutt å dele denne sesjonen",
"palette.search.placeholder": "Søk i filer og kommandoer",
"palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
"palette.empty": "Ingen resultater funnet",
"palette.group.commands": "Kommandoer",
"palette.group.files": "Filer",
@@ -349,6 +351,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk",
"toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning",
"toast.workspace.enabled.title": "Arbeidsområder aktivert",
"toast.workspace.enabled.description": "Flere worktrees vises nå i sidefeltet",
"toast.workspace.disabled.title": "Arbeidsområder deaktivert",
"toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet",
"toast.model.none.title": "Ingen modell valgt",
"toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen",

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Otwórz ustawienia",
"command.session.previous": "Poprzednia sesja",
"command.session.next": "Następna sesja",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Zarchiwizuj sesję",
"command.palette": "Paleta poleceń",
@@ -42,7 +44,6 @@ export const dict = {
"command.session.new": "Nowa sesja",
"command.file.open": "Otwórz plik",
"command.file.open.description": "Szukaj plików i poleceń",
"command.context.addSelection": "Dodaj zaznaczenie do kontekstu",
"command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku",
"command.terminal.toggle": "Przełącz terminal",
@@ -68,6 +69,7 @@ export const dict = {
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
"command.workspace.toggle": "Przełącz przestrzenie robocze",
"command.session.undo": "Cofnij",
"command.session.undo.description": "Cofnij ostatnią wiadomość",
"command.session.redo": "Ponów",
@@ -81,7 +83,7 @@ export const dict = {
"command.session.unshare": "Przestań udostępniać sesję",
"command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
"palette.search.placeholder": "Szukaj plików i poleceń",
"palette.search.placeholder": "Szukaj plików, poleceń i sesji",
"palette.empty": "Brak wyników",
"palette.group.commands": "Polecenia",
"palette.group.files": "Pliki",
@@ -347,6 +349,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji",
"toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia",
"toast.workspace.enabled.title": "Przestrzenie robocze włączone",
"toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym",
"toast.workspace.disabled.title": "Przestrzenie robocze wyłączone",
"toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym",
"toast.model.none.title": "Nie wybrano modelu",
"toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję",

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "Открыть настройки",
"command.session.previous": "Предыдущая сессия",
"command.session.next": "Следующая сессия",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Архивировать сессию",
"command.palette": "Палитра команд",
@@ -42,7 +44,6 @@ export const dict = {
"command.session.new": "Новая сессия",
"command.file.open": "Открыть файл",
"command.file.open.description": "Поиск файлов и команд",
"command.context.addSelection": "Добавить выделение в контекст",
"command.context.addSelection.description": "Добавить выбранные строки из текущего файла",
"command.terminal.toggle": "Переключить терминал",
@@ -68,6 +69,7 @@ export const dict = {
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
"command.permissions.autoaccept.enable": "Авто-принятие изменений",
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
"command.workspace.toggle": "Переключить рабочие пространства",
"command.session.undo": "Отменить",
"command.session.undo.description": "Отменить последнее сообщение",
"command.session.redo": "Повторить",
@@ -81,7 +83,7 @@ export const dict = {
"command.session.unshare": "Отменить публикацию",
"command.session.unshare.description": "Прекратить публикацию сессии",
"palette.search.placeholder": "Поиск файлов и команд",
"palette.search.placeholder": "Поиск файлов, команд и сессий",
"palette.empty": "Ничего не найдено",
"palette.group.commands": "Команды",
"palette.group.files": "Файлы",
@@ -348,6 +350,11 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "Авто-принятие остановлено",
"toast.permissions.autoaccept.off.description": "Редактирование и запись потребуют подтверждения",
"toast.workspace.enabled.title": "Рабочие пространства включены",
"toast.workspace.enabled.description": "В боковой панели теперь отображаются несколько рабочих деревьев",
"toast.workspace.disabled.title": "Рабочие пространства отключены",
"toast.workspace.disabled.description": "В боковой панели отображается только главное рабочее дерево",
"toast.model.none.title": "Модель не выбрана",
"toast.model.none.description": "Подключите провайдера для суммаризации сессии",

View File

@@ -28,6 +28,8 @@ export const dict = {
"command.settings.open": "เปิดการตั้งค่า",
"command.session.previous": "เซสชันก่อนหน้า",
"command.session.next": "เซสชันถัดไป",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "จัดเก็บเซสชัน",
"command.palette": "คำสั่งค้นหา",
@@ -42,7 +44,6 @@ export const dict = {
"command.session.new": "เซสชันใหม่",
"command.file.open": "เปิดไฟล์",
"command.file.open.description": "ค้นหาไฟล์และคำสั่ง",
"command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท",
"command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน",
"command.terminal.toggle": "สลับเทอร์มินัล",
@@ -68,6 +69,7 @@ export const dict = {
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
"command.workspace.toggle": "สลับพื้นที่ทำงาน",
"command.session.undo": "ยกเลิก",
"command.session.undo.description": "ยกเลิกข้อความล่าสุด",
"command.session.redo": "ทำซ้ำ",
@@ -81,7 +83,7 @@ export const dict = {
"command.session.unshare": "ยกเลิกการแชร์เซสชัน",
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
"palette.search.placeholder": "ค้นหาไฟล์และคำสั่ง",
"palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
"palette.empty": "ไม่พบผลลัพธ์",
"palette.group.commands": "คำสั่ง",
"palette.group.files": "ไฟล์",
@@ -347,10 +349,15 @@ export const dict = {
"toast.scheme.title": "โทนสี",
"toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ",
"toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและเขียนจะได้รับการอนุมัติโดยอัตโนมัติ",
"toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและจะได้รับเขียนการอนุมัติโดยอัตโนมัติ",
"toast.permissions.autoaccept.off.title": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
"toast.permissions.autoaccept.off.description": "สิทธิ์การแก้ไขและเขียนจะต้องได้รับการอนุมัติ",
"toast.workspace.enabled.title": "เปิดใช้งานพื้นที่ทำงานแล้ว",
"toast.workspace.enabled.description": "ตอนนี้จะแสดง worktree หลายรายการในแถบด้านข้าง",
"toast.workspace.disabled.title": "ปิดใช้งานพื้นที่ทำงานแล้ว",
"toast.workspace.disabled.description": "จะแสดงเฉพาะ worktree หลักในแถบด้านข้าง",
"toast.model.none.title": "ไม่ได้เลือกโมเดล",
"toast.model.none.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้",

View File

@@ -32,6 +32,8 @@ export const dict = {
"command.settings.open": "打开设置",
"command.session.previous": "上一个会话",
"command.session.next": "下一个会话",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "归档会话",
"command.palette": "命令面板",
@@ -46,7 +48,6 @@ export const dict = {
"command.session.new": "新建会话",
"command.file.open": "打开文件",
"command.file.open.description": "搜索文件和命令",
"command.context.addSelection": "将所选内容添加到上下文",
"command.context.addSelection.description": "添加当前文件中选中的行",
"command.terminal.toggle": "切换终端",
@@ -72,6 +73,7 @@ export const dict = {
"command.model.variant.cycle.description": "切换到下一个强度等级",
"command.permissions.autoaccept.enable": "自动接受编辑",
"command.permissions.autoaccept.disable": "停止自动接受编辑",
"command.workspace.toggle": "切换工作区",
"command.session.undo": "撤销",
"command.session.undo.description": "撤销上一条消息",
"command.session.redo": "重做",
@@ -85,7 +87,7 @@ export const dict = {
"command.session.unshare": "取消分享会话",
"command.session.unshare.description": "停止分享此会话",
"palette.search.placeholder": "搜索文件命令",
"palette.search.placeholder": "搜索文件命令和会话",
"palette.empty": "未找到结果",
"palette.group.commands": "命令",
"palette.group.files": "文件",
@@ -342,7 +344,12 @@ export const dict = {
"toast.language.description": "已切换到{{language}}",
"toast.theme.title": "主题已切换",
"toast.scheme.title": "色方案",
"toast.scheme.title": "色方案",
"toast.workspace.enabled.title": "工作区已启用",
"toast.workspace.enabled.description": "侧边栏现在显示多个工作树",
"toast.workspace.disabled.title": "工作区已禁用",
"toast.workspace.disabled.description": "侧边栏只显示主工作树",
"toast.permissions.autoaccept.on.title": "自动接受编辑",
"toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批",

View File

@@ -32,6 +32,8 @@ export const dict = {
"command.settings.open": "開啟設定",
"command.session.previous": "上一個工作階段",
"command.session.next": "下一個工作階段",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "封存工作階段",
"command.palette": "命令面板",
@@ -46,7 +48,6 @@ export const dict = {
"command.session.new": "新增工作階段",
"command.file.open": "開啟檔案",
"command.file.open.description": "搜尋檔案和命令",
"command.context.addSelection": "將選取內容加入上下文",
"command.context.addSelection.description": "加入目前檔案中選取的行",
"command.terminal.toggle": "切換終端機",
@@ -72,6 +73,7 @@ export const dict = {
"command.model.variant.cycle.description": "切換到下一個強度等級",
"command.permissions.autoaccept.enable": "自動接受編輯",
"command.permissions.autoaccept.disable": "停止自動接受編輯",
"command.workspace.toggle": "切換工作區",
"command.session.undo": "復原",
"command.session.undo.description": "復原上一則訊息",
"command.session.redo": "重做",
@@ -85,7 +87,7 @@ export const dict = {
"command.session.unshare": "取消分享工作階段",
"command.session.unshare.description": "停止分享此工作階段",
"palette.search.placeholder": "搜尋檔案命令",
"palette.search.placeholder": "搜尋檔案命令和工作階段",
"palette.empty": "找不到結果",
"palette.group.commands": "命令",
"palette.group.files": "檔案",
@@ -339,7 +341,12 @@ export const dict = {
"toast.language.description": "已切換到 {{language}}",
"toast.theme.title": "主題已切換",
"toast.scheme.title": "色方案",
"toast.scheme.title": "色方案",
"toast.workspace.enabled.title": "工作區已啟用",
"toast.workspace.enabled.description": "側邊欄現在顯示多個工作樹",
"toast.workspace.disabled.title": "工作區已停用",
"toast.workspace.disabled.description": "側邊欄只顯示主工作樹",
"toast.permissions.autoaccept.on.title": "自動接受編輯",
"toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准",

View File

@@ -31,6 +31,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -108,7 +109,7 @@ export default function Layout(props: ParentProps) {
const command = useCommand()
const theme = useTheme()
const language = useLanguage()
const initialDir = params.dir
const initialDirectory = decode64(params.dir)
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -119,7 +120,7 @@ export default function Layout(props: ParentProps) {
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const [state, setState] = createStore({
autoselect: !params.dir,
autoselect: !initialDirectory,
busyWorkspaces: new Set<string>(),
hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined,
@@ -179,13 +180,21 @@ export default function Layout(props: ParentProps) {
const autoselecting = createMemo(() => {
if (params.dir) return false
if (initialDir) return false
if (!state.autoselect) return false
if (!pageReady()) return true
if (!layoutReady()) return true
const list = layout.projects.list()
if (list.length === 0) return false
return true
if (list.length > 0) return true
return !!server.projects.last()
})
createEffect(() => {
if (!state.autoselect) return
const dir = params.dir
if (!dir) return
const directory = decode64(dir)
if (!directory) return
setState("autoselect", false)
})
const editorOpen = (id: string) => editor.active === id
@@ -498,7 +507,7 @@ export default function Layout(props: ParentProps) {
const bUpdated = b.time.updated ?? b.time.created
const aRecent = aUpdated > oneMinuteAgo
const bRecent = bUpdated > oneMinuteAgo
if (aRecent && bRecent) return a.id.localeCompare(b.id)
if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
if (aRecent && !bRecent) return -1
if (!aRecent && bRecent) return 1
return bUpdated - aUpdated
@@ -565,11 +574,18 @@ export default function Layout(props: ParentProps) {
if (!value.ready) return
if (!value.layoutReady) return
if (!state.autoselect) return
if (initialDir) return
if (value.dir) return
if (value.list.length === 0) return
const last = server.projects.last()
if (value.list.length === 0) {
if (!last) return
setState("autoselect", false)
openProject(last, false)
navigateToProject(last)
return
}
const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
if (!next) return
setState("autoselect", false)
@@ -738,7 +754,7 @@ export default function Layout(props: ParentProps) {
}
async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [, setStore] = globalSync.child(directory, { bootstrap: false })
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
@@ -749,23 +765,49 @@ export default function Layout(props: ParentProps) {
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const current = store.message[sessionID] ?? []
const merged = (() => {
if (current.length === 0) return next
const map = new Map<string, Message>()
for (const item of current) {
if (!item?.id) continue
map.set(item.id, item)
}
for (const item of next) {
map.set(item.id, item)
}
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})()
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
setStore("message", sessionID, reconcile(merged, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
const currentParts = store.part[message.info.id] ?? []
const mergedParts = (() => {
if (currentParts.length === 0) {
return message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}
const map = new Map<string, (typeof currentParts)[number]>()
for (const item of currentParts) {
if (!item?.id) continue
map.set(item.id, item)
}
for (const item of message.parts) {
if (!item?.id) continue
map.set(item.id, item)
}
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})()
setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
}
})
})
@@ -886,6 +928,52 @@ export default function Layout(props: ParentProps) {
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
}
function navigateSessionByUnseen(offset: number) {
const sessions = currentSessions()
if (sessions.length === 0) return
const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0)
if (!hasUnseen) return
const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex
for (let i = 1; i <= sessions.length; i++) {
const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
const session = sessions[index]
if (!session) continue
if (notification.session.unseen(session.id).length === 0) continue
prefetchSession(session, "high")
const next = sessions[(index + 1) % sessions.length]
const prev = sessions[(index - 1 + sessions.length) % sessions.length]
if (offset > 0) {
if (next) prefetchSession(next, "high")
if (prev) prefetchSession(prev)
}
if (offset < 0) {
if (prev) prefetchSession(prev, "high")
if (next) prefetchSession(next)
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup",
})
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return
}
}
async function archiveSession(session: Session) {
const [store, setStore] = globalSync.child(session.directory)
const sessions = store.session ?? []
@@ -912,69 +1000,6 @@ export default function Layout(props: ParentProps) {
}
}
async function deleteSession(session: Session) {
const [store, setStore] = globalSync.child(session.directory)
const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
const result = await globalSDK.client.session
.delete({ directory: session.directory, sessionID: session.id })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return
setStore(
produce((draft) => {
const removed = new Set<string>([session.id])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [session.id]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
if (session.id === params.id) {
if (nextSession) {
navigate(`/${params.dir}/session/${nextSession.id}`)
} else {
navigate(`/${params.dir}/session`)
}
}
}
command.register(() => {
const commands: CommandOption[] = [
{
@@ -1024,6 +1049,20 @@ export default function Layout(props: ParentProps) {
keybind: "alt+arrowdown",
onSelect: () => navigateSessionByOffset(1),
},
{
id: "session.previous.unseen",
title: language.t("command.session.previous.unseen"),
category: language.t("command.category.session"),
keybind: "shift+alt+arrowup",
onSelect: () => navigateSessionByUnseen(-1),
},
{
id: "session.next.unseen",
title: language.t("command.session.next.unseen"),
category: language.t("command.category.session"),
keybind: "shift+alt+arrowdown",
onSelect: () => navigateSessionByUnseen(1),
},
{
id: "session.archive",
title: language.t("command.session.archive"),
@@ -1035,6 +1074,29 @@ export default function Layout(props: ParentProps) {
if (session) archiveSession(session)
},
},
{
id: "workspace.toggle",
title: language.t("command.workspace.toggle"),
description: language.t("command.workspace.toggle.description"),
category: language.t("command.category.workspace"),
slash: "workspace",
disabled: !currentProject() || currentProject()?.vcs !== "git",
onSelect: () => {
const project = currentProject()
if (!project) return
if (project.vcs !== "git") return
const wasEnabled = layout.sidebar.workspaces(project.worktree)()
layout.sidebar.toggleWorkspaces(project.worktree)
showToast({
title: wasEnabled
? language.t("toast.workspace.disabled.title")
: language.t("toast.workspace.enabled.title"),
description: wasEnabled
? language.t("toast.workspace.disabled.description")
: language.t("toast.workspace.enabled.description"),
})
},
},
{
id: "theme.cycle",
title: language.t("command.theme.cycle"),
@@ -1191,15 +1253,6 @@ export default function Layout(props: ParentProps) {
globalSync.project.meta(project.worktree, { name })
}
async function renameSession(session: Session, next: string) {
if (next === session.title) return
await globalSDK.client.session.update({
directory: session.directory,
sessionID: session.id,
title: next,
})
}
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
if (current === next) return
@@ -1350,33 +1403,6 @@ export default function Layout(props: ParentProps) {
})
}
function DialogDeleteSession(props: { session: Session }) {
const handleDelete = async () => {
await deleteSession(props.session)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: props.session.title })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
function DialogDeleteWorkspace(props: { root: string; directory: string }) {
const name = createMemo(() => getFilename(props.directory))
const [data, setData] = createStore({
@@ -1730,10 +1756,6 @@ export default function Layout(props: ParentProps) {
const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
const [menu, setMenu] = createStore({
open: false,
pendingRename: false,
})
const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
const cancelHoverPrefetch = () => {
@@ -1760,7 +1782,7 @@ export default function Layout(props: ParentProps) {
const item = (
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menu.open ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerEnter={scheduleHoverPrefetch}
onPointerLeave={cancelHoverPrefetch}
onMouseEnter={scheduleHoverPrefetch}
@@ -1792,14 +1814,9 @@ export default function Layout(props: ParentProps) {
</Match>
</Switch>
</div>
<InlineEditor
id={`session:${props.session.id}`}
value={() => props.session.title}
onSave={(next) => renameSession(props.session, next)}
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -1847,7 +1864,10 @@ export default function Layout(props: ParentProps) {
getLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive()) {
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
layout.pendingMessage.set(
`${base64Encode(props.session.directory)}/${props.session.id}`,
message.id,
)
navigate(`${props.slug}/session/${props.session.id}`)
return
}
@@ -1864,49 +1884,25 @@ export default function Layout(props: ParentProps) {
<div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{
"opacity-100 pointer-events-auto": menu.open,
"opacity-0 pointer-events-none": !menu.open,
"opacity-100 pointer-events-auto": !!props.mobile,
"opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<DropdownMenu modal={!sidebarHovering()} open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
<Tooltip value={language.t("common.moreOptions")} placement="top">
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
</Tooltip>
<DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
<DropdownMenu.Content
onCloseAutoFocus={(event) => {
if (!menu.pendingRename) return
event.preventDefault()
setMenu("pendingRename", false)
openEditor(`session:${props.session.id}`, props.session.title)
}}
>
<DropdownMenu.Item
onSelect={() => {
setMenu("pendingRename", true)
setMenu("open", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => archiveSession(props.session)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession session={props.session} />)}>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void archiveSession(props.session)
}}
/>
</Tooltip>
</div>
</div>
)
@@ -2250,10 +2246,13 @@ export default function Layout(props: ParentProps) {
() => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
)
const [open, setOpen] = createSignal(false)
const [menu, setMenu] = createSignal(false)
const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree))
const active = createMemo(
() => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree),
)
createEffect(() => {
if (preview()) return
@@ -2291,50 +2290,95 @@ export default function Layout(props: ParentProps) {
}
const projectName = () => props.project.name || getFilename(props.project.worktree)
const trigger = (
<button
type="button"
aria-label={projectName()}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!selected() && !active(),
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
const Trigger = () => (
<ContextMenu
modal={!sidebarHovering()}
onOpenChange={(value) => {
setMenu(value)
if (value) setOpen(false)
}}
onMouseEnter={() => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
}}
onFocus={() => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
}}
onClick={() => navigateToProject(props.project.worktree)}
onBlur={() => setOpen(false)}
>
<ProjectIcon project={props.project} notify />
</button>
<ContextMenu.Trigger
as="button"
type="button"
aria-label={projectName()}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!selected() && !active(),
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
}}
onMouseEnter={() => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
}}
onFocus={() => {
if (!overlay()) return
globalSync.child(props.project.worktree)
setState("hoverProject", props.project.worktree)
setState("hoverSession", undefined)
}}
onClick={() => navigateToProject(props.project.worktree)}
onBlur={() => setOpen(false)}
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? state.nav : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}>
<ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !layout.sidebar.workspaces(props.project.worktree)()}
onSelect={() => {
const enabled = layout.sidebar.workspaces(props.project.worktree)()
if (enabled) {
layout.sidebar.toggleWorkspaces(props.project.worktree)
return
}
if (props.project.vcs !== "git") return
layout.sidebar.toggleWorkspaces(props.project.worktree)
}}
>
<ContextMenu.ItemLabel>
{layout.sidebar.workspaces(props.project.worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview()} fallback={trigger}>
<Show when={preview()} fallback={<Trigger />}>
<HoverCard
open={open()}
open={open() && !menu()}
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={6}
trigger={trigger}
trigger={<Trigger />}
onOpenChange={(value) => {
if (menu()) return
setOpen(value)
if (value) setState("hoverSession", undefined)
}}
@@ -2555,7 +2599,7 @@ export default function Layout(props: ParentProps) {
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
>
<Show when={panelProps.project} keyed>
<Show when={panelProps.project}>
{(p) => (
<>
<div class="shrink-0 px-2 py-1">
@@ -2564,7 +2608,7 @@ export default function Layout(props: ParentProps) {
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => renameProject(p, next)}
onSave={(next) => renameProject(p(), next)}
class="text-16-medium text-text-strong truncate"
displayClass="text-16-medium text-text-strong truncate"
stopPropagation
@@ -2573,7 +2617,7 @@ export default function Layout(props: ParentProps) {
<Tooltip
placement="bottom"
gutter={2}
value={p.worktree}
value={p().worktree}
class="shrink-0"
contentStyle={{
"max-width": "640px",
@@ -2581,7 +2625,7 @@ export default function Layout(props: ParentProps) {
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{p.worktree.replace(homedir(), "~")}
{p().worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
@@ -2592,31 +2636,31 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={base64Encode(p.worktree)}
data-project={base64Encode(p().worktree)}
class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(p.worktree)}
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
data-project={base64Encode(p().worktree)}
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
onSelect={() => {
const enabled = layout.sidebar.workspaces(p.worktree)()
const enabled = layout.sidebar.workspaces(p().worktree)()
if (enabled) {
layout.sidebar.toggleWorkspaces(p.worktree)
layout.sidebar.toggleWorkspaces(p().worktree)
return
}
if (p.vcs !== "git") return
layout.sidebar.toggleWorkspaces(p.worktree)
if (p().vcs !== "git") return
layout.sidebar.toggleWorkspaces(p().worktree)
}}
>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p.worktree)()
{layout.sidebar.workspaces(p().worktree)()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
@@ -2624,8 +2668,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={base64Encode(p.worktree)}
onSelect={() => closeProject(p.worktree)}
data-project={base64Encode(p().worktree)}
onSelect={() => closeProject(p().worktree)}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -2635,103 +2679,109 @@ export default function Layout(props: ParentProps) {
</div>
</div>
<Show
when={workspacesEnabled()}
fallback={
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
navigate(`/${base64Encode(p().worktree)}/session`)
layout.mobileSidebar.hide()
}}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace project={p()} mobile={panelProps.mobile} />
</div>
</>
}
>
<>
<div class="py-4 px-3">
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
navigate(`/${base64Encode(p.worktree)}/session`)
layout.mobileSidebar.hide()
}}
>
{language.t("command.session.new")}
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace project={p} mobile={panelProps.mobile} />
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace directory={directory} project={p()} mobile={panelProps.mobile} />
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
</>
}
>
<>
<div class="py-4 px-3">
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace directory={directory} project={p} mobile={panelProps.mobile} />
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</Show>
</div>
</>
)}
</Show>
<Show when={providers.all().length > 0 && providers.paid().length === 0}>
<div class="shrink-0 px-2 py-3 border-t border-border-weak-base">
<div class="rounded-md bg-background-base shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
</div>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
size="large"
icon="plus"
onClick={connectProvider}
>
{language.t("command.provider.connect")}
</Button>
<div
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
classList={{
hidden: !(providers.all().length > 0 && providers.paid().length === 0),
}}
>
<div class="rounded-md bg-background-base shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
</div>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
size="large"
icon="plus"
onClick={connectProvider}
>
{language.t("command.provider.connect")}
</Button>
</div>
</Show>
</div>
</div>
)
}

View File

@@ -16,13 +16,16 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
@@ -73,10 +76,31 @@ import { same } from "@/utils/same"
type DiffStyle = "unified" | "split"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const HANDOFF_MAX = 40
const handoff = {
prompt: "",
terminals: [] as string[],
files: {} as Record<string, SelectedLineRange | null>,
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > HANDOFF_MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
touch(handoff.session, key, { ...prev, ...patch })
}
interface SessionReviewTabProps {
@@ -280,9 +304,47 @@ export default function Page() {
.finally(() => setUi("responding", false))
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
createEffect(
on(
() => params.id,
(id, prev) => {
if (!id) return
if (prev) return
const pending = layout.handoff.tabs()
if (!pending) return
if (Date.now() - pending.at > 60_000) {
layout.handoff.clearTabs()
return
}
if (pending.id !== id) return
layout.handoff.clearTabs()
if (pending.dir !== (params.dir ?? "")) return
const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return
const current = tabs().tabs()
if (current.all.length > 0 || current.active) return
const all = normalizeTabs(from.all)
const active = from.active ? normalizeTab(from.active) : undefined
tabs().setAll(all)
tabs().setActive(active && all.includes(active) ? active : all[0])
workspaceTabs().setAll([])
workspaceTabs().setActive(undefined)
},
{ defer: true },
),
)
if (import.meta.env.DEV) {
createEffect(
on(
@@ -398,6 +460,213 @@ export default function Page() {
if (!id) return false
return sync.session.history.loading(id)
})
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
() => params.id,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!params.id) return
setTitle({ editing: true, draft: info()?.title ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const sessionID = params.id
if (!sessionID) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (info()?.title ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
if (params.id !== sessionID) return
if (session.parentID) {
navigate(`/${params.dir}/session/${session.parentID}`)
return
}
if (nextSession) {
navigate(`/${params.dir}/session/${nextSession.id}`)
return
}
navigate(`/${params.dir}/session`)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function deleteSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
if (params.id !== sessionID) return true
if (session.parentID) {
navigate(`/${params.dir}/session/${session.parentID}`)
return true
}
if (nextSession) {
navigate(`/${params.dir}/session/${nextSession.id}`)
return true
}
navigate(`/${params.dir}/session`)
return true
}
function DialogDeleteSession(props: { sessionID: string }) {
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: title() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -500,9 +769,7 @@ export default function Page() {
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const add = diff.additions > 0
const del = diff.deletions > 0
const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix"
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
@@ -547,8 +814,10 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
createEffect(() => {
if (!params.id) return
sync.session.sync(params.id)
sdk.directory
const id = params.id
if (!id) return
sync.session.sync(id)
})
createEffect(() => {
@@ -616,10 +885,22 @@ export default function Page() {
createEffect(
on(
() => params.id,
sessionKey,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
setUi("autoCreated", false)
},
{ defer: true },
),
)
createEffect(
on(
() => params.dir,
(dir) => {
if (!dir) return
setStore("newSessionWorktree", "main")
},
{ defer: true },
),
@@ -683,12 +964,24 @@ export default function Page() {
{
id: "file.open",
title: language.t("command.file.open"),
description: language.t("command.file.open.description"),
description: language.t("palette.search.placeholder"),
category: language.t("command.category.file"),
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={() => showAllFiles()} />),
},
{
id: "tab.close",
title: language.t("command.tab.close"),
category: language.t("command.category.file"),
keybind: "mod+w",
disabled: !tabs().active(),
onSelect: () => {
const active = tabs().active()
if (!active) return
tabs().close(active)
},
},
{
id: "context.addSelection",
title: language.t("command.context.addSelection"),
@@ -1115,12 +1408,15 @@ export default function Page() {
activeDiff: undefined as string | undefined,
})
const reviewScroll = () => tree.reviewScroll
const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
const pendingDiff = () => tree.pendingDiff
const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
const activeDiff = () => tree.activeDiff
const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value)
createEffect(
on(
sessionKey,
() => {
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
},
{ defer: true },
),
)
const showAllFiles = () => {
if (fileTreeTab() !== "changes") return
@@ -1141,8 +1437,8 @@ export default function Page() {
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onScrollRef={setReviewScroll}
focusedFile={activeDiff()}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
@@ -1192,7 +1488,7 @@ export default function Page() {
}
const reviewDiffTop = (path: string) => {
const root = reviewScroll()
const root = tree.reviewScroll
if (!root) return
const id = reviewDiffId(path)
@@ -1208,7 +1504,7 @@ export default function Page() {
}
const scrollToReviewDiff = (path: string) => {
const root = reviewScroll()
const root = tree.reviewScroll
if (!root) return false
const top = reviewDiffTop(path)
@@ -1222,24 +1518,23 @@ export default function Page() {
const focusReviewDiff = (path: string) => {
const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path])
setActiveDiff(path)
setPendingDiff(path)
setTree({ activeDiff: path, pendingDiff: path })
}
createEffect(() => {
const pending = pendingDiff()
const pending = tree.pendingDiff
if (!pending) return
if (!reviewScroll()) return
if (!tree.reviewScroll) return
if (!diffsReady()) return
const attempt = (count: number) => {
if (pendingDiff() !== pending) return
if (tree.pendingDiff !== pending) return
if (count > 60) {
setPendingDiff(undefined)
setTree("pendingDiff", undefined)
return
}
const root = reviewScroll()
const root = tree.reviewScroll
if (!root) {
requestAnimationFrame(() => attempt(count + 1))
return
@@ -1257,7 +1552,7 @@ export default function Page() {
}
if (Math.abs(root.scrollTop - top) <= 1) {
setPendingDiff(undefined)
setTree("pendingDiff", undefined)
return
}
@@ -1300,13 +1595,17 @@ export default function Page() {
void sync.session.diff(id)
})
let treeDir: string | undefined
createEffect(() => {
const dir = sdk.directory
if (!isDesktop()) return
if (!layout.fileTree.opened()) return
if (sync.status === "loading") return
fileTreeTab()
void file.tree.list("")
const refresh = treeDir !== dir
treeDir = dir
void (refresh ? file.tree.refresh("") : file.tree.list(""))
})
const autoScroll = createAutoScroll({
@@ -1341,6 +1640,18 @@ export default function Page() {
let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | undefined
createEffect(
on(
sessionKey,
() => {
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
scrollSpyFrame = undefined
scrollSpyTarget = undefined
},
{ defer: true },
),
)
const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
@@ -1455,20 +1766,14 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
const raw = sessionStorage.getItem("opencode.pendingMessage")
if (!raw) return
const parts = raw.split("|")
const pendingSessionID = parts[0]
const messageID = parts[1]
if (!pendingSessionID || !messageID) return
if (pendingSessionID !== sessionID) return
sessionStorage.removeItem("opencode.pendingMessage")
setUi("pendingMessage", messageID)
})
createEffect(
on(sessionKey, (key) => {
if (!params.id) return
const messageID = layout.pendingMessage.consume(key)
if (!messageID) return
setUi("pendingMessage", messageID)
}),
)
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
@@ -1682,7 +1987,7 @@ export default function Page() {
createEffect(() => {
if (!prompt.ready()) return
handoff.prompt = previewPrompt()
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
createEffect(() => {
@@ -1702,20 +2007,22 @@ export default function Page() {
return language.t("terminal.title")
}
handoff.terminals = terminal.all().map(label)
touch(handoff.terminal, params.dir!, terminal.all().map(label))
})
createEffect(() => {
if (!file.ready()) return
handoff.files = Object.fromEntries(
tabs()
.all()
.flatMap((tab) => {
const path = file.pathFromTab(tab)
if (!path) return []
return [[path, file.selectedLines(path) ?? null] as const]
}),
)
setSessionHandoff(sessionKey(), {
files: Object.fromEntries(
tabs()
.all()
.flatMap((tab) => {
const path = file.pathFromTab(tab)
if (!path) return []
return [[path, file.selectedLines(path) ?? null] as const]
}),
),
})
})
onCleanup(() => {
@@ -1761,7 +2068,7 @@ export default function Page() {
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 pt-6 md:pt-3": true,
"flex-1 pt-2 md:pt-3": true,
"md:flex-none": layout.fileTree.opened(),
}}
style={{
@@ -1791,7 +2098,7 @@ export default function Page() {
diffs={diffs}
view={view}
diffStyle="unified"
focusedFile={activeDiff()}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
@@ -1940,23 +2247,112 @@ export default function Page() {
"sticky top-0 z-30 bg-background-stronger": true,
"w-full": true,
"px-4 md:px-6": true,
"md:max-w-200 md:mx-auto": centered(),
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]":
centered(),
}}
>
<div class="h-10 flex items-center gap-1">
<Show when={info()?.parentID}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={info()?.title}>
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
<div class="h-10 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Show when={info()?.parentID}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={info()?.title || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-16-medium text-text-strong truncate min-w-0"
onDblClick={openTitleEditor}
>
{info()?.title}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-16-medium text-text-strong grow-1 min-w-0"
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={() => closeTitleEditor()}
/>
</Show>
</Show>
</div>
<Show when={params.id}>
{(id) => (
<div class="shrink-0 flex items-center">
<DropdownMenu
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<Tooltip value={language.t("common.moreOptions")} placement="top">
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingRename: true, menuOpen: false })
}}
>
<DropdownMenu.ItemLabel>
{language.t("common.rename")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>
{language.t("common.archive")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>
{language.t("common.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
@@ -1965,10 +2361,11 @@ export default function Page() {
<div
ref={autoScroll.contentRef}
role="log"
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto": centered(),
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]":
centered(),
"mt-0.5": centered(),
"mt-0": !centered(),
}}
@@ -2021,7 +2418,7 @@ export default function Page() {
data-message-id={message.id}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200": centered(),
"md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
}}
>
<SessionTurn
@@ -2078,7 +2475,7 @@ export default function Page() {
<div
classList={{
"w-full px-4 pointer-events-auto": true,
"md:max-w-200 md:mx-auto": centered(),
"md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
}}
>
<Show when={request()} keyed>
@@ -2135,7 +2532,7 @@ export default function Page() {
when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoff.prompt || language.t("prompt.loading")}
{handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
</div>
}
>
@@ -2386,7 +2783,7 @@ export default function Page() {
const p = path()
if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null
return handoff.files[p] ?? null
return handoff.session.get(sessionKey())?.files[p] ?? null
})
let wrap: HTMLDivElement | undefined
@@ -2880,7 +3277,7 @@ export default function Page() {
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={activeDiff()}
active={tree.activeDiff}
onFileClick={(node) => focusReviewDiff(node.path)}
/>
</Show>
@@ -2940,7 +3337,7 @@ export default function Page() {
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={handoff.terminals}>
<For each={handoff.terminal.get(params.dir!) ?? []}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}

View File

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

View File

@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "80K",
full: "80,000",
compact: "95K",
full: "95,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "600",
commits: "7,500",
monthlyUsers: "1.5M",
contributors: "650",
commits: "8,500",
monthlyUsers: "2.5M",
},
} as const

View File

@@ -79,7 +79,7 @@ export async function handler(
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
const isTrial = await trialLimiter?.isTrial()
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip)
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request.headers)
await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
const stickyProvider = await stickyTracker?.get()

View File

@@ -2,13 +2,19 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error"
import { logger } from "./logger"
import { ZenData } from "@opencode-ai/console-core/model.js"
export function createRateLimiter(limit: number | undefined, rawIp: string) {
export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string, headers: Headers) {
if (!limit) return
const limitValue = limit.checkHeader && !headers.get(limit.checkHeader) ? limit.fallbackValue! : limit.value
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
const intervals =
limit.period === "day"
? [buildYYYYMMDD(now)]
: [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
return {
track: async () => {
@@ -28,11 +34,18 @@ export function createRateLimiter(limit: number | undefined, rawIp: string) {
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
if (total >= limitValue) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
},
}
}
function buildYYYYMMDD(timestamp: number) {
return new Date(timestamp)
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 8)
}
function buildYYYYMMDDHH(timestamp: number) {
return new Date(timestamp)
.toISOString()

View File

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

View File

@@ -18,8 +18,15 @@ export namespace ZenData {
}),
),
})
const RateLimitSchema = z.object({
period: z.enum(["day", "rolling"]),
value: z.number().int(),
checkHeader: z.string().optional(),
fallbackValue: z.number().int().optional(),
})
export type Format = z.infer<typeof FormatSchema>
export type Trial = z.infer<typeof TrialSchema>
export type RateLimit = z.infer<typeof RateLimitSchema>
const ModelCostSchema = z.object({
input: z.number(),
@@ -37,7 +44,7 @@ export namespace ZenData {
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(),
trial: TrialSchema.optional(),
rateLimit: z.number().optional(),
rateLimit: RateLimitSchema.optional(),
fallbackProvider: z.string().optional(),
providers: z.array(
z.object({

View File

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

View File

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

View File

@@ -17,7 +17,7 @@
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh p-px"></div>
<div id="root" class="flex flex-col h-dvh"></div>
<div data-tauri-decorum-tb class="w-0 h-0 hidden" />
<script src="/src/index.tsx" type="module"></script>
</body>

View File

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

View File

@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "adler2"
version = "2.0.1"
@@ -1994,9 +2000,9 @@ dependencies = [
[[package]]
name = "ico"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png 0.17.16",
@@ -3065,12 +3071,14 @@ dependencies = [
"listeners",
"objc2 0.6.3",
"objc2-web-kit",
"reqwest",
"reqwest 0.12.24",
"semver",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-build",
"tauri-build 2.5.2",
"tauri-plugin-clipboard-manager",
"tauri-plugin-decorum",
"tauri-plugin-deep-link",
@@ -3085,6 +3093,7 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-specta",
"tokio",
"uuid",
"webkit2gtk",
@@ -3221,6 +3230,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3947,6 +3962,40 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
]
[[package]]
name = "rfd"
version = "0.15.4"
@@ -4497,6 +4546,44 @@ dependencies = [
"system-deps",
]
[[package]]
name = "specta"
version = "2.0.0-rc.22"
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
dependencies = [
"paste",
"rustc_version",
"specta-macros",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.18"
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "specta-serde"
version = "0.0.9"
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
dependencies = [
"specta",
]
[[package]]
name = "specta-typescript"
version = "0.0.9"
source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
dependencies = [
"specta",
"specta-serde",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -4712,9 +4799,8 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e492485dd390b35f7497401f67694f46161a2a00ffd800938d5dd3c898fb9d8"
version = "2.9.5"
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
dependencies = [
"anyhow",
"bytes",
@@ -4740,17 +4826,18 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.1",
"serde",
"serde_json",
"serde_repr",
"serialize-to-javascript",
"specta",
"swift-rs",
"tauri-build",
"tauri-build 2.5.3",
"tauri-macros",
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"tauri-utils 2.8.1",
"thiserror 2.0.17",
"tokio",
"tray-icon",
@@ -4777,7 +4864,28 @@ dependencies = [
"semver",
"serde",
"serde_json",
"tauri-utils",
"tauri-utils 2.8.0",
"tauri-winres",
"toml 0.9.8",
"walkdir",
]
[[package]]
name = "tauri-build"
version = "2.5.3"
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"glob",
"heck 0.5.0",
"json-patch",
"schemars 0.8.22",
"semver",
"serde",
"serde_json",
"tauri-utils 2.8.1",
"tauri-winres",
"toml 0.9.8",
"walkdir",
@@ -4785,9 +4893,8 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f"
version = "2.5.2"
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -4802,7 +4909,7 @@ dependencies = [
"serde_json",
"sha2",
"syn 2.0.110",
"tauri-utils",
"tauri-utils 2.8.1",
"thiserror 2.0.17",
"time",
"url",
@@ -4812,16 +4919,15 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d"
version = "2.5.2"
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.110",
"tauri-codegen",
"tauri-utils",
"tauri-utils 2.8.1",
]
[[package]]
@@ -4836,7 +4942,7 @@ dependencies = [
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"tauri-utils 2.8.0",
"toml 0.9.8",
"walkdir",
]
@@ -4886,7 +4992,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"tauri-utils 2.8.0",
"thiserror 2.0.17",
"tracing",
"url",
@@ -4928,7 +5034,7 @@ dependencies = [
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"tauri-utils 2.8.0",
"thiserror 2.0.17",
"toml 0.9.8",
"url",
@@ -4945,7 +5051,7 @@ dependencies = [
"data-url",
"http",
"regex",
"reqwest",
"reqwest 0.12.24",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -5096,7 +5202,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.12.24",
"semver",
"serde",
"serde_json",
@@ -5129,9 +5235,8 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926"
version = "2.9.2"
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
dependencies = [
"cookie",
"dpi",
@@ -5144,7 +5249,7 @@ dependencies = [
"raw-window-handle",
"serde",
"serde_json",
"tauri-utils",
"tauri-utils 2.8.1",
"thiserror 2.0.17",
"url",
"webkit2gtk",
@@ -5154,9 +5259,8 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93"
version = "2.9.3"
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
dependencies = [
"gtk",
"http",
@@ -5171,7 +5275,7 @@ dependencies = [
"softbuffer",
"tao",
"tauri-runtime",
"tauri-utils",
"tauri-utils 2.8.1",
"url",
"webkit2gtk",
"webview2-com",
@@ -5179,11 +5283,74 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.21"
source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-specta-macros",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-specta-macros"
version = "2.0.0-rc.16"
source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b"
dependencies = [
"darling",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "tauri-utils"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
dependencies = [
"anyhow",
"cargo_metadata",
"ctor",
"dunce",
"glob",
"html5ever",
"http",
"infer",
"json-patch",
"kuchikiki",
"log",
"memchr",
"phf 0.11.3",
"proc-macro2",
"quote",
"regex",
"schemars 0.8.22",
"semver",
"serde",
"serde-untagged",
"serde_json",
"serde_with",
"swift-rs",
"thiserror 2.0.17",
"toml 0.9.8",
"url",
"urlpattern",
"uuid",
"walkdir",
]
[[package]]
name = "tauri-utils"
version = "2.8.1"
source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
dependencies = [
"anyhow",
"brotli",
@@ -5547,9 +5714,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.6"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.10.0",
"bytes",
@@ -6034,9 +6201,9 @@ dependencies = [
[[package]]
name = "webkit2gtk"
version = "2.0.1"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a"
checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
dependencies = [
"bitflags 1.3.2",
"cairo-rs",
@@ -6058,9 +6225,9 @@ dependencies = [
[[package]]
name = "webkit2gtk-sys"
version = "2.0.1"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c"
checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
dependencies = [
"bitflags 1.3.2",
"cairo-sys-rs",
@@ -6719,9 +6886,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "wry"
version = "0.53.5"
version = "0.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2"
checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0"
dependencies = [
"base64 0.22.1",
"block2 0.6.2",

View File

@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "devtools"] }
tauri = { version = "2.9.5", features = ["macos-private-api", "devtools"] }
tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2.4.6"
tauri-plugin-shell = "2"
@@ -43,10 +43,13 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
uuid = { version = "1.19.0", features = ["v4"] }
tauri-plugin-decorum = "1.1.1"
comrak = { version = "0.50", default-features = false }
specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"
webkit2gtk = "=2.0.2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
@@ -59,3 +62,10 @@ windows = { version = "0.61", features = [
"Win32_System_Threading",
"Win32_Security"
] }
[patch.crates-io]
specta = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "6720b2848eff9a3e40af54c48d65f6d56b640c0b" }
# TODO: https://github.com/tauri-apps/tauri/pull/14812
tauri = { git = "https://github.com/tauri-apps/tauri", rev = "4d5d78daf636feaac20c5bc48a6071491c4291ee" }

View File

@@ -51,6 +51,7 @@ fn is_cli_installed() -> bool {
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
#[tauri::command]
#[specta::specta]
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
if cfg!(not(unix)) {
return Err("CLI installation is only supported on macOS & Linux".to_string());

View File

@@ -16,21 +16,26 @@ use std::{
time::{Duration, Instant},
};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder};
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
use tauri_plugin_deep_link::DeepLinkExt;
#[cfg(windows)]
use tauri_plugin_decorum::WebviewWindowExt;
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot;
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::{mpsc, oneshot};
use crate::window_customizer::PinchZoomDisablePlugin;
const SETTINGS_STORE: &str = "opencode.settings.dat";
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
#[derive(Clone, serde::Serialize)]
fn window_state_flags() -> StateFlags {
StateFlags::all() - StateFlags::DECORATIONS
}
#[derive(Clone, serde::Serialize, specta::Type)]
struct ServerReadyData {
url: String,
password: Option<String>,
@@ -64,6 +69,7 @@ struct LogState(Arc<Mutex<VecDeque<String>>>);
const MAX_LOG_ENTRIES: usize = 200;
#[tauri::command]
#[specta::specta]
fn kill_sidecar(app: AppHandle) {
let Some(server_state) = app.try_state::<ServerState>() else {
println!("Server not running");
@@ -97,6 +103,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
#[specta::specta]
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
state
.status
@@ -106,6 +113,7 @@ async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerRead
}
#[tauri::command]
#[specta::specta]
fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
let store = app
.store(SETTINGS_STORE)
@@ -119,6 +127,7 @@ fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
}
#[tauri::command]
#[specta::specta]
async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
let store = app
.store(SETTINGS_STORE)
@@ -252,6 +261,26 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
let builder = tauri_specta::Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(tauri_specta::collect_commands![
kill_sidecar,
install_cli,
ensure_server_ready,
get_default_server_url,
set_default_server_url,
markdown::parse_markdown_command
])
.error_handling(tauri_specta::ErrorHandlingMode::Throw);
#[cfg(debug_assertions)] // <- Only export on non-release builds
builder
.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
#[cfg(all(target_os = "macos", not(debug_assertions)))]
let _ = std::process::Command::new("killall")
.arg("opencode-cli")
@@ -269,10 +298,7 @@ pub fn run() {
.plugin(tauri_plugin_os::init())
.plugin(
tauri_plugin_window_state::Builder::new()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
- tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.with_state_flags(window_state_flags())
.build(),
)
.plugin(tauri_plugin_store::Builder::new().build())
@@ -285,15 +311,10 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.plugin(PinchZoomDisablePlugin)
.plugin(tauri_plugin_decorum::init())
.invoke_handler(tauri::generate_handler![
kill_sidecar,
install_cli,
ensure_server_ready,
get_default_server_url,
set_default_server_url,
markdown::parse_markdown_command
])
.invoke_handler(builder.invoke_handler())
.setup(move |app| {
builder.mount_events(app);
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
app.deep_link().register_all().ok();
@@ -346,6 +367,8 @@ pub fn run() {
let window = window_builder.build().expect("Failed to create window");
setup_window_state_listener(&app, &window);
#[cfg(windows)]
let _ = window.create_overlay_titlebar();
@@ -526,6 +549,7 @@ async fn spawn_local_server(
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(30) {
let _ = child.kill();
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
@@ -540,3 +564,35 @@ async fn spawn_local_server(
}
}
}
fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWindow) {
let (tx, mut rx) = mpsc::channel::<()>(1);
window.on_window_event(move |event| {
use tauri::WindowEvent;
if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) {
return;
}
let _ = tx.try_send(());
});
tauri::async_runtime::spawn({
let app = app.clone();
async move {
let save = || {
let handle = app.clone();
let app = app.clone();
let _ = handle.run_on_main_thread(move || {
let _ = app.save_window_state(window_state_flags());
});
};
while rx.recv().await.is_some() {
tokio::time::sleep(Duration::from_millis(200)).await;
save();
}
}
});
}

View File

@@ -1,4 +1,6 @@
use comrak::{create_formatter, parse_document, Arena, Options, html::ChildRendering, nodes::NodeValue};
use comrak::{
Arena, Options, create_formatter, html::ChildRendering, nodes::NodeValue, parse_document,
};
use std::fmt::Write;
create_formatter!(ExternalLinkFormatter, {
@@ -55,6 +57,7 @@ pub fn parse_markdown(input: &str) -> String {
}
#[tauri::command]
#[specta::specta]
pub async fn parse_markdown_command(markdown: String) -> Result<String, String> {
Ok(parse_markdown(&markdown))
}

View File

@@ -1,4 +1,4 @@
use tauri::{plugin::Plugin, Manager, Runtime, Window};
use tauri::{Manager, Runtime, Window, plugin::Plugin};
pub struct PinchZoomDisablePlugin;
@@ -21,8 +21,8 @@ impl<R: Runtime> Plugin<R> for PinchZoomDisablePlugin {
let _ = webview_window.with_webview(|_webview| {
#[cfg(target_os = "linux")]
unsafe {
use gtk::glib::ObjectExt;
use gtk::GestureZoom;
use gtk::glib::ObjectExt;
use webkit2gtk::glib::gobject_ffi;
if let Some(data) = _webview.inner().data::<GestureZoom>("wk-view-zoom-gesture") {

View File

@@ -42,6 +42,13 @@
"active": true,
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
"externalBin": ["sidecars/opencode-cli"],
"linux": {
"rpm": {
"compression": {
"type": "none"
}
}
},
"macOS": {
"entitlements": "./entitlements.plist"
},

View File

@@ -21,6 +21,11 @@
"files": {
"/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml"
}
},
"rpm": {
"compression": {
"type": "none"
}
}
}
},

View File

@@ -0,0 +1,19 @@
// This file has been generated by Tauri Specta. Do not edit this file manually.
import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"
/** Commands */
export const commands = {
killSidecar: () => __TAURI_INVOKE<void>("kill_sidecar"),
installCli: () => __TAURI_INVOKE<string>("install_cli"),
ensureServerReady: () => __TAURI_INVOKE<ServerReadyData>("ensure_server_ready"),
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
}
/* Types */
export type ServerReadyData = {
url: string
password: string | null
}

View File

@@ -1,13 +1,13 @@
import { invoke } from "@tauri-apps/api/core"
import { message } from "@tauri-apps/plugin-dialog"
import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export async function installCli(): Promise<void> {
await initI18n()
try {
const path = await invoke<string>("install_cli")
const path = await commands.installCli()
await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") })
} catch (e) {
await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") })

View File

@@ -1,5 +1,5 @@
// @refresh reload
import "./webview-zoom"
import { webviewZoom } from "./webview-zoom"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
@@ -7,7 +7,6 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater"
import { invoke } from "@tauri-apps/api/core"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
@@ -22,6 +21,7 @@ import { createMenu } from "./menu"
import { initI18n, t } from "./i18n"
import pkg from "../package.json"
import "./styles.css"
import { commands } from "./bindings"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -274,12 +274,12 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
update: async () => {
if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
await update.install().catch(() => undefined)
},
restart: async () => {
await invoke("kill_sidecar").catch(() => undefined)
await commands.killSidecar().catch(() => undefined)
await relaunch()
},
@@ -335,17 +335,17 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
},
getDefaultServerUrl: async () => {
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
const result = await commands.getDefaultServerUrl().catch(() => null)
return result
},
setDefaultServerUrl: async (url: string | null) => {
await invoke("set_default_server_url", { url })
await commands.setDefaultServerUrl(url)
},
parseMarkdown: async (markdown: string) => {
return invoke<string>("parse_markdown_command", { markdown })
},
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
webviewZoom,
})
createMenu()
@@ -391,11 +391,7 @@ type ServerReadyData = { url: string; password: string | null }
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
const [serverData] = createResource<ServerReadyData>(() =>
invoke("ensure_server_ready").then((v) => {
return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000))
}),
)
const [serverData] = createResource(() => commands.ensureServerReady())
const errorMessage = () => {
const error = serverData.error
@@ -406,7 +402,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
}
const restartApp = async () => {
await invoke("kill_sidecar").catch(() => undefined)
await commands.killSidecar().catch(() => undefined)
await relaunch().catch(() => undefined)
}

View File

@@ -1,11 +1,11 @@
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
import { type as ostype } from "@tauri-apps/plugin-os"
import { invoke } from "@tauri-apps/api/core"
import { relaunch } from "@tauri-apps/plugin-process"
import { runUpdater, UPDATER_ENABLED } from "./updater"
import { installCli } from "./cli"
import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export async function createMenu() {
if (ostype() !== "macos") return
@@ -35,7 +35,7 @@ export async function createMenu() {
}),
await MenuItem.new({
action: async () => {
await invoke("kill_sidecar").catch(() => undefined)
await commands.killSidecar().catch(() => undefined)
await relaunch().catch(() => undefined)
},
text: t("desktop.menu.restart"),

View File

@@ -1,10 +1,10 @@
import { check } from "@tauri-apps/plugin-updater"
import { relaunch } from "@tauri-apps/plugin-process"
import { ask, message } from "@tauri-apps/plugin-dialog"
import { invoke } from "@tauri-apps/api/core"
import { type as ostype } from "@tauri-apps/plugin-os"
import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
@@ -39,13 +39,13 @@ export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
if (!shouldUpdate) return
try {
if (ostype() === "windows") await invoke("kill_sidecar")
if (ostype() === "windows") await commands.killSidecar()
await update.install()
} catch {
await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") })
return
}
await invoke("kill_sidecar")
await commands.killSidecar()
await relaunch()
}

View File

@@ -4,28 +4,34 @@
import { invoke } from "@tauri-apps/api/core"
import { type as ostype } from "@tauri-apps/plugin-os"
import { createSignal } from "solid-js"
const OS_NAME = ostype()
let zoomLevel = 1
const [webviewZoom, setWebviewZoom] = createSignal(1)
const MAX_ZOOM_LEVEL = 10
const MIN_ZOOM_LEVEL = 0.2
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
const applyZoom = (next: number) => {
setWebviewZoom(next)
invoke("plugin:webview|set_webview_zoom", {
value: next,
})
}
window.addEventListener("keydown", (event) => {
if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) {
if (event.key === "-") {
zoomLevel -= 0.2
} else if (event.key === "=" || event.key === "+") {
zoomLevel += 0.2
} else if (event.key === "0") {
zoomLevel = 1
} else {
return
}
zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
invoke("plugin:webview|set_webview_zoom", {
value: zoomLevel,
})
}
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
let newZoom = webviewZoom()
if (event.key === "-") newZoom -= 0.2
if (event.key === "=" || event.key === "+") newZoom += 0.2
if (event.key === "0") newZoom = 1
applyZoom(clamp(newZoom))
})
export { webviewZoom }

View File

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

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.48"
version = "1.1.51"
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.1.48/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/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.1.48/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/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.1.48/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.48",
"version": "1.1.51",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -20,6 +20,7 @@
"bin": {
"opencode": "./bin/opencode"
},
"randomField": "this-is-a-random-value-12345",
"exports": {
"./*": "./src/*.ts"
},
@@ -70,7 +71,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.56",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.3.1",
"@gitlab/gitlab-ai-provider": "3.4.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -82,8 +83,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.76",
"@opentui/solid": "0.1.76",
"@opentui/core": "0.1.77",
"@opentui/solid": "0.1.77",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -91,8 +92,9 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.4",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",

View File

@@ -18,6 +18,7 @@ const version = Object.values(binaries)[0]
await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin`
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
@@ -30,6 +31,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
},
version: version,
license: pkg.license,
optionalDependencies: binaries,
},
null,

View File

@@ -18,6 +18,7 @@ import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
export namespace Agent {
export const Info = z
@@ -50,13 +51,14 @@ export namespace Agent {
const state = Instance.state(async () => {
const cfg = await Config.get()
const skillDirs = await Skill.dirs()
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow",
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
},
question: "deny",
plan_enter: "deny",
@@ -140,7 +142,6 @@ export namespace Agent {
codesearch: "allow",
read: "allow",
external_directory: {
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow",
},
}),
@@ -229,19 +230,19 @@ export namespace Agent {
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.DIR is allowed unless explicitly configured
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in result) {
const agent = result[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.DIR || r.pattern === Truncate.GLOB
return r.pattern === Truncate.GLOB
})
if (explicit) continue
result[name].permission = PermissionNext.merge(
result[name].permission,
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow" } }),
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}

View File

@@ -5,12 +5,11 @@ import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { createRequire } from "module"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
export namespace BunProc {
const log = Log.create({ service: "bun" })
const req = createRequire(import.meta.url)
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", {
@@ -75,7 +74,17 @@ export namespace BunProc {
const dependencies = parsed.dependencies ?? {}
if (!parsed.dependencies) parsed.dependencies = dependencies
const modExists = await Filesystem.exists(mod)
if (dependencies[pkg] === version && modExists) return 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 })
}
const proxied = !!(
process.env.HTTP_PROXY ||

View File

@@ -0,0 +1,48 @@
import { readableStreamToText, semver } from "bun"
import { Log } from "../util/log"
export namespace PackageRegistry {
const log = Log.create({ service: "bun" })
function which() {
return process.execPath
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
const result = Bun.spawn([which(), "info", pkg, field], {
cwd,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
const code = await result.exited
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
if (code !== 0) {
log.warn("bun info failed", { pkg, field, code, stderr })
return null
}
const value = stdout.trim()
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.order(cachedVersion, latestVersion) === -1
}
}

View File

@@ -4,25 +4,211 @@ import { UI } from "../ui"
import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { Command } from "../../command"
import { EOL } from "os"
import { select } from "@clack/prompts"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission/next"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ListTool } from "../../tool/ls"
import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
const TOOL: Record<string, [string, string]> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
list: ["List", UI.Style.TEXT_INFO_BOLD],
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
type ToolProps<T extends Tool.Info> = {
input: Tool.InferParameters<T>
metadata: Tool.InferMetadata<T>
part: ToolPart
}
function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
const state = part.state
return {
input: state.input as Tool.InferParameters<T>,
metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
part,
}
}
type Inline = {
icon: string
title: string
description?: string
}
function inline(info: Inline) {
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
}
function block(info: Inline, output?: string) {
UI.empty()
inline(info)
if (!output?.trim()) return
UI.println(output)
UI.empty()
}
function fallback(part: ToolPart) {
const state = part.state
const input = "input" in state ? state.input : undefined
const title =
("title" in state && state.title ? state.title : undefined) ||
(input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
inline({
icon: "⚙",
title: `${part.tool} ${title}`,
})
}
function glob(info: ToolProps<typeof GlobTool>) {
const root = info.input.path ?? ""
const title = `Glob "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.count
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function grep(info: ToolProps<typeof GrepTool>) {
const root = info.input.path ?? ""
const title = `Grep "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.matches
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function list(info: ToolProps<typeof ListTool>) {
const dir = info.input.path ? normalizePath(info.input.path) : ""
inline({
icon: "→",
title: dir ? `List ${dir}` : "List",
})
}
function read(info: ToolProps<typeof ReadTool>) {
const file = normalizePath(info.input.filePath)
const pairs = Object.entries(info.input).filter(([key, value]) => {
if (key === "filePath") return false
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
})
const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined
inline({
icon: "→",
title: `Read ${file}`,
...(description && { description }),
})
}
function write(info: ToolProps<typeof WriteTool>) {
block(
{
icon: "←",
title: `Write ${normalizePath(info.input.filePath)}`,
},
info.part.state.status === "completed" ? info.part.state.output : undefined,
)
}
function webfetch(info: ToolProps<typeof WebFetchTool>) {
inline({
icon: "%",
title: `WebFetch ${info.input.url}`,
})
}
function edit(info: ToolProps<typeof EditTool>) {
const title = normalizePath(info.input.filePath)
const diff = info.metadata.diff
block(
{
icon: "←",
title: `Edit ${title}`,
},
diff,
)
}
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
inline({
icon: "◇",
title: `Exa Code Search "${info.input.query}"`,
})
}
function websearch(info: ToolProps<typeof WebSearchTool>) {
inline({
icon: "◈",
title: `Exa Web Search "${info.input.query}"`,
})
}
function task(info: ToolProps<typeof TaskTool>) {
const agent = Locale.titlecase(info.input.subagent_type)
const desc = info.input.description
const started = info.part.state.status === "running"
const name = desc ?? `${agent} Task`
inline({
icon: started ? "•" : "✓",
title: name,
description: desc ? `${agent} Agent` : undefined,
})
}
function skill(info: ToolProps<typeof SkillTool>) {
inline({
icon: "→",
title: `Skill "${info.input.name}"`,
})
}
function bash(info: ToolProps<typeof BashTool>) {
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
block(
{
icon: "$",
title: `${info.input.command}`,
},
output,
)
}
function todo(info: ToolProps<typeof TodoWriteTool>) {
block(
{
icon: "#",
title: "Todos",
},
info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"),
)
}
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
return input
}
export const RunCommand = cmd({
@@ -91,17 +277,22 @@ export const RunCommand = cmd({
type: "string",
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
})
.option("thinking", {
type: "boolean",
describe: "show thinking blocks",
default: false,
})
},
handler: async (args) => {
let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
const fileParts: any[] = []
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) {
const files = Array.isArray(args.file) ? args.file : [args.file]
const list = Array.isArray(args.file) ? args.file : [args.file]
for (const filePath of files) {
for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath)
const file = Bun.file(resolvedPath)
const stats = await file.stat().catch(() => {})
@@ -117,7 +308,7 @@ export const RunCommand = cmd({
const stat = await file.stat()
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
fileParts.push({
files.push({
type: "file",
url: `file://${resolvedPath}`,
filename: path.basename(resolvedPath),
@@ -133,17 +324,75 @@ export const RunCommand = cmd({
process.exit(1)
}
const execute = async (sdk: OpencodeClient, sessionID: string) => {
const printEvent = (color: string, type: string, title: string) => {
UI.println(
color + `|`,
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
"",
UI.Style.TEXT_NORMAL + title,
)
const rules: PermissionNext.Ruleset = [
{
permission: "question",
action: "deny",
pattern: "*",
},
{
permission: "plan_enter",
action: "deny",
pattern: "*",
},
{
permission: "plan_exit",
action: "deny",
pattern: "*",
},
]
function title() {
if (args.title === undefined) return
if (args.title !== "") return args.title
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient) {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
}
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!res.error && "data" in res && res.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
}
}
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "list") return list(props<typeof ListTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part))
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
return fallback(part)
}
const outputJsonEvent = (type: string, data: any) => {
function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true
@@ -152,41 +401,77 @@ export const RunCommand = cmd({
}
const events = await sdk.event.subscribe()
let errorMsg: string | undefined
let error: string | undefined
async function loop() {
const toggles = new Map<string, boolean>()
const eventProcessor = (async () => {
for await (const event of events.stream) {
if (
event.type === "message.updated" &&
event.properties.info.role === "assistant" &&
args.format !== "json" &&
toggles.get("start") !== true
) {
UI.empty()
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
UI.empty()
toggles.set("start", true)
}
if (event.type === "message.part.updated") {
const part = event.properties.part
if (part.sessionID !== sessionID) continue
if (part.type === "tool" && part.state.status === "completed") {
if (outputJsonEvent("tool_use", { part })) continue
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title ||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
printEvent(color, tool, title)
if (part.tool === "bash" && part.state.output?.trim()) {
UI.println()
UI.println(part.state.output)
}
if (emit("tool_use", { part })) continue
tool(part)
}
if (
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
args.format !== "json"
) {
if (toggles.get(part.id) === true) continue
task(props<typeof TaskTool>(part))
toggles.set(part.id, true)
}
if (part.type === "step-start") {
if (outputJsonEvent("step_start", { part })) continue
if (emit("step_start", { part })) continue
}
if (part.type === "step-finish") {
if (outputJsonEvent("step_finish", { part })) continue
if (emit("step_finish", { part })) continue
}
if (part.type === "text" && part.time?.end) {
if (outputJsonEvent("text", { part })) continue
const isPiped = !process.stdout.isTTY
if (!isPiped) UI.println()
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
if (!isPiped) UI.println()
if (emit("text", { part })) continue
const text = part.text.trim()
if (!text) continue
if (!process.stdout.isTTY) {
process.stdout.write(text + EOL)
continue
}
UI.empty()
UI.println(text)
UI.empty()
}
if (part.type === "reasoning" && part.time?.end && args.thinking) {
if (emit("reasoning", { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (process.stdout.isTTY) {
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
continue
}
process.stdout.write(line + EOL)
}
}
@@ -197,42 +482,40 @@ export const RunCommand = cmd({
if ("data" in props.error && props.error.data && "message" in props.error.data) {
err = String(props.error.data.message)
}
errorMsg = errorMsg ? errorMsg + EOL + err : err
if (outputJsonEvent("error", { error: props.error })) continue
error = error ? error + EOL + err : err
if (emit("error", { error: props.error })) continue
UI.error(err)
}
if (event.type === "session.idle" && event.properties.sessionID === sessionID) {
if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
event.properties.status.type === "idle"
) {
break
}
if (event.type === "permission.asked") {
const permission = event.properties
if (permission.sessionID !== sessionID) continue
const result = await select({
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
options: [
{ value: "once", label: "Allow once" },
{ value: "always", label: "Always allow: " + permission.always.join(", ") },
{ value: "reject", label: "Reject" },
],
initialValue: "once",
}).catch(() => "reject")
const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject"
await sdk.permission.respond({
sessionID,
permissionID: permission.id,
response,
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
await sdk.permission.reply({
requestID: permission.id,
reply: "reject",
})
}
}
})()
}
// Validate agent if specified
const resolvedAgent = await (async () => {
const agent = await (async () => {
if (!args.agent) return undefined
const agent = await Agent.get(args.agent)
if (!agent) {
const entry = await Agent.get(args.agent)
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
@@ -240,7 +523,7 @@ export const RunCommand = cmd({
)
return undefined
}
if (agent.mode === "subagent") {
if (entry.mode === "subagent") {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL,
@@ -251,91 +534,42 @@ export const RunCommand = cmd({
return args.agent
})()
const sessionID = await session(sdk)
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
await share(sdk, sessionID)
loop().catch((e) => {
console.error(e)
process.exit(1)
})
if (args.command) {
await sdk.session.command({
sessionID,
agent: resolvedAgent,
agent,
model: args.model,
command: args.command,
arguments: message,
variant: args.variant,
})
} else {
const modelParam = args.model ? Provider.parseModel(args.model) : undefined
const model = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({
sessionID,
agent: resolvedAgent,
model: modelParam,
agent,
model,
variant: args.variant,
parts: [...fileParts, { type: "text", text: message }],
parts: [...files, { type: "text", text: message }],
})
}
await eventProcessor
if (errorMsg) process.exit(1)
}
if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach })
const sessionID = await (async () => {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const title =
args.title !== undefined
? args.title === ""
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
: args.title
: undefined
const result = await sdk.session.create(
title
? {
title,
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}
: {
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
},
)
return result.data?.id
})()
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
const cfgResult = await sdk.config.get()
if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
const shareResult = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
return await execute(sdk, sessionID)
return await execute(sdk)
}
await bootstrap(process.cwd(), async () => {
@@ -344,52 +578,7 @@ export const RunCommand = cmd({
return Server.App().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
if (args.command) {
const exists = await Command.get(args.command)
if (!exists) {
UI.error(`Command "${args.command}" not found`)
process.exit(1)
}
}
const sessionID = await (async () => {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const title =
args.title !== undefined
? args.title === ""
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
: args.title
: undefined
const result = await sdk.session.create(title ? { title } : {})
return result.data?.id
})()
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
const cfgResult = await sdk.config.get()
if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
const shareResult = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
await execute(sdk, sessionID)
await execute(sdk)
})
},
})

View File

@@ -169,6 +169,7 @@ export function tui(input: {
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
@@ -186,7 +187,6 @@ function App() {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
Clipboard.setRenderer(renderer)
renderer.disableStdoutInterception()
const dialog = useDialog()
const local = useLocal()

View File

@@ -129,6 +129,16 @@ export function Autocomplete(props: {
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
})
// filter() reads reactive props.value plus non-reactive cursor/text state.
// On keypress those can be briefly out of sync, so filter() may return an empty/partial string.
// Copy it into search in an effect because effects run after reactive updates have been rendered and painted
// so the input has settled and all consumers read the same stable value.
const [search, setSearch] = createSignal("")
createEffect(() => {
const next = filter()
setSearch(next ? next : "")
})
// When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
// that the mouseover event doesn't trigger when filtering.
@@ -208,7 +218,7 @@ export function Autocomplete(props: {
}
const [files] = createResource(
() => filter(),
() => search(),
async (query) => {
if (!store.visible || store.visible === "/") return []
@@ -378,9 +388,9 @@ export function Autocomplete(props: {
const mixed: AutocompleteOption[] =
store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
const currentFilter = filter()
const searchValue = search()
if (!currentFilter) {
if (!searchValue) {
return mixed
}
@@ -388,7 +398,7 @@ export function Autocomplete(props: {
return prev
}
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
const result = fuzzysort.go(removeLineRange(searchValue), mixed, {
keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description",
@@ -398,7 +408,7 @@ export function Autocomplete(props: {
scoreFn: (objResults) => {
const displayResult = objResults[0]
let score = objResults.score
if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
score *= 2
}
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0

View File

@@ -1,23 +1,52 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { FormatError, FormatUnknownError } from "@/cli/error"
type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
clear: () => void
get: () => string | undefined
}
}
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
return async (reason?: any) => {
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
let message: string | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
}
process.exit(0)
},
clear: () => {
message = undefined
},
get: () => message,
}
const exit: Exit = Object.assign(
async (reason?: unknown) => {
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
process.exit(0)
},
{
message: store,
},
)
return exit
},
})

View File

@@ -35,6 +35,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
@@ -48,6 +49,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
theme.warning,
theme.primary,
theme.error,
theme.info,
])
return {
list() {
@@ -75,11 +77,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
},
color(name: string) {
const all = sync.data.agent
const agent = all.find((x) => x.name === name)
if (agent?.color) return RGBA.fromHex(agent.color)
const index = all.findIndex((x) => x.name === name)
const index = visibleAgents().findIndex((x) => x.name === name)
if (index === -1) return colors()[0]
const agent = visibleAgents()[index]
if (agent?.color) {
const color = agent.color
if (color.startsWith("#")) return RGBA.fromHex(color)
// already validated by config, just satisfying TS here
return theme[color as keyof typeof theme] as RGBA
}
return colors()[index % colors().length]
},
}

View File

@@ -41,7 +41,6 @@ import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useSDK } from "./sdk"
type ThemeColors = {
primary: RGBA
@@ -429,6 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromInts(0, 0, 0, 0)
const isDark = mode == "dark"
const col = (i: number) => {
@@ -479,8 +479,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
textMuted,
selectedListItemText: bg,
// Background colors
background: bg,
// Background colors - use transparent to respect terminal transparency
background: transparent,
backgroundPanel: grays[2],
backgroundElement: grays[3],
backgroundMenu: grays[3],

View File

@@ -77,6 +77,7 @@ import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
addDefaultParsers(parsers.parsers)
@@ -222,6 +223,19 @@ export function Session() {
// Allow exit when in child session (prompt is hidden)
const exit = useExit()
createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50)
return exit.message.set(
[
``,
` █▀▀█ ${UI.Style.TEXT_DIM}${title}${UI.Style.TEXT_NORMAL}`,
` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`,
` ▀▀▀▀ `,
].join("\n"),
)
})
useKeyboard((evt) => {
if (!session()?.parentID) return
if (keybind.match("app_exit", evt)) {

View File

@@ -1,12 +1,23 @@
import { $ } from "bun"
import type { CliRenderer } from "@opentui/core"
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
const rendererRef = { current: undefined as CliRenderer | undefined }
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
export namespace Clipboard {
export interface Content {
@@ -14,10 +25,6 @@ export namespace Clipboard {
mime: string
}
export function setRenderer(renderer: CliRenderer | undefined): void {
rendererRef.current = renderer
}
export async function read(): Promise<Content | undefined> {
const os = platform()
@@ -146,11 +153,7 @@ export namespace Clipboard {
})
export async function copy(text: string): Promise<void> {
const renderer = rendererRef.current
if (renderer) {
const copied = renderer.copyToClipboardOSC52(text)
if (copied) return
}
writeOsc52(text)
await getCopyMethod()(text)
}
}

View File

@@ -63,7 +63,7 @@ export const WebCommand = cmd({
UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL,
`opencode.local:${server.port}`,
`${opts.mdnsDomain}:${server.port}`,
)
}

View File

@@ -17,6 +17,11 @@ const options = {
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
default: false,
},
"mdns-domain": {
type: "string" as const,
describe: "custom domain name for mDNS service (default: opencode.local)",
default: "opencode.local",
},
cors: {
type: "string" as const,
array: true,
@@ -36,9 +41,11 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
const corsExplicitlySet = process.argv.includes("--cors")
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"])
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
const hostname = hostnameExplicitlySet
? args.hostname
@@ -49,5 +56,5 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
const cors = [...configCors, ...argsCors]
return { hostname, port, mdns, cors }
return { hostname, port, mdns, mdnsDomain, cors }
}

View File

@@ -28,6 +28,7 @@ import { existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -62,8 +63,14 @@ export namespace Config {
export const state = Instance.state(async () => {
const auth = await Auth.all()
// Load remote/well-known config first as the base layer (lowest precedence)
// This allows organizations to provide default configs that users can override
// Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
// 1) Remote .well-known/opencode (org defaults)
// 2) Global config (~/.config/opencode/opencode.json{,c})
// 3) Custom config (OPENCODE_CONFIG)
// 4) Project config (opencode.json{,c})
// 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c})
// 6) Inline config (OPENCODE_CONFIG_CONTENT)
// Managed config directory is enterprise-only and always overrides everything above.
let result: Info = {}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
@@ -85,16 +92,16 @@ export namespace Config {
}
}
// Global user config overrides remote config
// Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global())
// Custom config path overrides global
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
// Project config has highest precedence (overrides global and remote)
// Project config overrides global and remote config.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
@@ -104,12 +111,6 @@ export namespace Config {
}
}
// Inline config content has highest precedence
if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
@@ -136,6 +137,7 @@ export namespace Config {
)),
]
// .opencode directory config overrides (project and global) config sources.
if (Flag.OPENCODE_CONFIG_DIR) {
directories.push(Flag.OPENCODE_CONFIG_DIR)
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
@@ -153,9 +155,10 @@ export namespace Config {
}
}
const exists = existsSync(path.join(dir, "node_modules"))
const installing = installDependencies(dir)
if (!exists) await installing
const shouldInstall = await needsInstall(dir)
if (shouldInstall) {
await installDependencies(dir)
}
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
@@ -163,6 +166,12 @@ export namespace Config {
result.plugin.push(...(await loadPlugin(dir)))
}
// Inline config content overrides all non-managed config sources.
if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
// Load managed config files last (highest priority) - enterprise admin-controlled
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions
@@ -228,6 +237,7 @@ export namespace Config {
export async function installDependencies(dir: string) {
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (!(await Bun.file(pkg).exists())) {
await Bun.write(pkg, "{}")
@@ -237,18 +247,43 @@ export namespace Config {
const hasGitIgnore = await Bun.file(gitignore).exists()
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
await BunProc.run(
["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"],
{
cwd: dir,
},
).catch(() => {})
await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
cwd: dir,
}).catch(() => {})
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
}
async function needsInstall(dir: string) {
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgFile = Bun.file(pkg)
const pkgExists = await pkgFile.exists()
if (!pkgExists) return true
const parsed = await pkgFile.json().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[]) {
for (const pattern of patterns) {
const index = item.indexOf(pattern)
@@ -610,10 +645,12 @@ export namespace Config {
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
options: z.record(z.string(), z.any()).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
.union([
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
.optional()
.describe("Hex color code for the agent (e.g., #FF5733)"),
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
steps: z
.number()
.int()
@@ -853,6 +890,7 @@ export namespace Config {
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
})
.strict()

View File

@@ -23,6 +23,8 @@ export namespace Flag {
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export const OPENCODE_DISABLE_EXTERNAL_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export declare const OPENCODE_CLIENT: string

View File

@@ -355,3 +355,12 @@ export const pint: Info = {
return false
},
}
export const ormolu: Info = {
name: "ormolu",
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return Bun.which("ormolu") !== null
},
}

View File

@@ -732,7 +732,7 @@ export namespace LSPServer {
export const CSharp: Info = {
id: "csharp",
root: NearestRoot([".sln", ".csproj", "global.json"]),
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = Bun.which("csharp-ls", {
@@ -772,7 +772,7 @@ export namespace LSPServer {
export const FSharp: Info = {
id: "fsharp",
root: NearestRoot([".sln", ".fsproj", "global.json"]),
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = Bun.which("fsautocomplete", {

View File

@@ -1,4 +1,5 @@
import z from "zod"
import os from "os"
import fuzzysort from "fuzzysort"
import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
@@ -35,8 +36,9 @@ import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel"
import { createGitLab } from "@gitlab/gitlab-ai-provider"
import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
import { ProviderTransform } from "./transform"
import { Installation } from "../installation"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -237,7 +239,9 @@ export namespace Provider {
options: providerOptions,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
// Skip region prefixing if model already has a cross-region inference profile prefix
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
// Models from models.dev may already include prefixes like us., eu., global., etc.
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) {
return sdk.languageModel(modelID)
}
@@ -424,11 +428,17 @@ export namespace Provider {
const config = await Config.get()
const providerConfig = config.provider?.["gitlab"]
const aiGatewayHeaders = {
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
...(providerConfig?.options?.aiGatewayHeaders || {}),
}
return {
autoload: !!apiKey,
options: {
instanceUrl,
apiKey,
aiGatewayHeaders,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
@@ -437,6 +447,7 @@ export namespace Provider {
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, {
aiGatewayHeaders,
featureFlags: {
duo_agent_platform_agentic_chat: true,
duo_agent_platform: true,
@@ -452,52 +463,36 @@ export namespace Provider {
if (!accountId || !gateway) return { autoload: false }
// Get API token from env or auth prompt
// Get API token from env or auth - required for authenticated gateways
const apiToken = await (async () => {
const envToken = Env.get("CLOUDFLARE_API_TOKEN")
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
if (envToken) return envToken
const auth = await Auth.get(input.id)
if (auth?.type === "api") return auth.key
return undefined
})()
if (!apiToken) {
throw new Error(
"CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " +
"Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.",
)
}
// Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility)
const { createAiGateway } = await import("ai-gateway-provider")
const { createUnified } = await import("ai-gateway-provider/providers/unified")
const aigateway = createAiGateway({ accountId, gateway, apiKey: apiToken })
const unified = createUnified()
return {
autoload: true,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.languageModel(modelID)
},
options: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
headers: {
// Cloudflare AI Gateway uses cf-aig-authorization for authenticated gateways
// This enables Unified Billing where Cloudflare handles upstream provider auth
...(apiToken ? { "cf-aig-authorization": `Bearer ${apiToken}` } : {}),
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
// Custom fetch to handle parameter transformation and auth
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
// Strip Authorization header - AI Gateway uses cf-aig-authorization instead
headers.delete("Authorization")
// Transform max_tokens to max_completion_tokens for newer models
if (init?.body && init.method === "POST") {
try {
const body = JSON.parse(init.body as string)
if (body.max_tokens !== undefined && !body.max_completion_tokens) {
body.max_completion_tokens = body.max_tokens
delete body.max_tokens
init = { ...init, body: JSON.stringify(body) }
}
} catch (e) {
// If body parsing fails, continue with original request
}
}
return fetch(input, { ...init, headers })
},
async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
// Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5")
return aigateway(unified(modelID))
},
options: {},
}
},
cerebras: async () => {

View File

@@ -18,12 +18,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
case "system": {
messages.push({
role: "system",
content: [
{
type: "text",
text: content,
},
],
content: content,
...metadata,
})
break

View File

@@ -1,5 +1,6 @@
import type { APICallError, ModelMessage } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
@@ -333,7 +334,9 @@ export namespace ProviderTransform {
id.includes("minimax") ||
id.includes("glm") ||
id.includes("mistral") ||
id.includes("kimi")
id.includes("kimi") ||
// TODO: Remove this after models.dev data is fixed to use "kimi-k2.5" instead of "k2p5"
id.includes("k2p5")
)
return {}
@@ -658,11 +661,18 @@ export namespace ProviderTransform {
}
export function smallOptions(model: Provider.Model) {
if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
if (model.api.id.includes("5.")) {
return { reasoningEffort: "low" }
if (
model.providerID === "openai" ||
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/github-copilot"
) {
if (model.api.id.includes("gpt-5")) {
if (model.api.id.includes("5.")) {
return { store: false, reasoningEffort: "low" }
}
return { store: false, reasoningEffort: "minimal" }
}
return { reasoningEffort: "minimal" }
return { store: false }
}
if (model.providerID === "google") {
// gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
@@ -710,7 +720,7 @@ export namespace ProviderTransform {
return standardLimit
}
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema) {
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 {
/*
if (["openai", "azure"].includes(providerID)) {
if (schema.type === "object" && schema.properties) {
@@ -761,8 +771,21 @@ export namespace ProviderTransform {
result.required = result.required.filter((field: any) => field in result.properties)
}
if (result.type === "array" && result.items == null) {
result.items = {}
if (result.type === "array") {
if (result.items == null) {
result.items = {}
}
// Ensure items has at least a type if it's an empty object
// This handles nested arrays like { type: "array", items: { type: "array", items: {} } }
if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) {
result.items.type = "string"
}
}
// Remove properties/required from non-object types (Gemini rejects these)
if (result.type && result.type !== "object") {
delete result.properties
delete result.required
}
return result
@@ -771,7 +794,7 @@ export namespace ProviderTransform {
schema = sanitizeGemini(schema)
}
return schema
return schema as JSONSchema7
}
export function error(providerID: string, error: APICallError) {

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