Compare commits

...

291 Commits

Author SHA1 Message Date
Adam
9c501b1583 wip(app): node-pty 2026-02-19 16:36:40 -06:00
Adam
fca0166488 fix(app): black screen on launch with sidecar server 2026-02-19 16:23:33 -06:00
opencode-agent[bot]
686dd330a0 chore: generate 2026-02-19 22:19:09 +00:00
tctev
193013a44d feat(opencode): support adaptive thinking for claude sonnet 4.6 (#14283)
Co-authored-by: tctev <224793535+tctev@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-02-19 16:17:57 -06:00
Yanosh Kunsh
824ab4cecc feat(tui): add custom tool and mcp call responses visible and collapsable (#10649)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-19 15:36:40 -06:00
Adam
7a42ecdddb chore: cleanup 2026-02-19 21:27:39 +00:00
Adam
dd011e879c fix(app): clear todos on abort 2026-02-19 21:27:39 +00:00
opencode
04cf2b8268 release: v1.2.7 2026-02-19 21:27:31 +00:00
Matt Silverlock
1a1437e78b fix(github): action branch detection and 422 handling (#14322)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-19 15:11:59 -06:00
Adam
c76a81434d chore: cleanup 2026-02-19 15:09:24 -06:00
Adam
49cc872c44 chore: refactor composer/dock components (#14328) 2026-02-19 15:02:45 -06:00
Adam
f8dad0ae17 fix(app): terminal issues (#14329) 2026-02-19 14:54:09 -06:00
Adam
40a939f5f0 chore: cleanup 2026-02-19 14:34:49 -06:00
Adam
7729c6d895 chore: cleanup 2026-02-19 14:31:22 -06:00
Adam
7fb2081dce chore: cleanup 2026-02-19 14:25:12 -06:00
Shintaro Jokagi
3d9f6c0fe0 feat(i18n): update Japanese translations to WSL integration (#13160) 2026-02-19 14:18:52 -06:00
Shantur Rathore
190d2957eb fix(core): normalize file.status paths relative to instance dir (#14207) 2026-02-19 14:17:36 -06:00
Jun
b64d0768ba docs(ko): improve wording in ecosystem, enterprise, formatters, and github docs (#14220) 2026-02-19 14:17:15 -06:00
opencode-agent[bot]
1867f1acaa chore: generate 2026-02-19 20:12:16 +00:00
Aiden Cline
00c079868a test: fix discovery test to boot up server instead of relying on 3rd party (#14327) 2026-02-19 14:11:23 -06:00
opencode-agent[bot]
8b99648790 chore: update nix node_modules hashes 2026-02-19 18:56:40 +00:00
Dax
cb8b74d3f1 refactor: migrate from Bun.Glob to npm glob package (#14317) 2026-02-19 13:40:09 -05:00
Aiden Cline
7e35d0c610 core: bump ai sdk packages for google, google vertex, anthropic, bedrock, and provider utils (#14318) 2026-02-19 12:35:51 -06:00
Aiden Cline
5364ab74a2 tweak: add support for medium reasoning w/ gemini 3.1 (#14316) 2026-02-19 12:00:56 -06:00
opencode-agent[bot]
91f8dd5f57 chore: update nix node_modules hashes 2026-02-19 18:00:22 +00:00
opencode-agent[bot]
850402f093 chore: update nix node_modules hashes 2026-02-19 17:52:03 +00:00
Dax Raad
af72010e9f Revert "refactor: migrate from Bun.Glob to npm glob package"
This reverts commit 3c21735b35.
2026-02-19 12:48:43 -05:00
Brendan Allan
50883cc1e9 app: make localhost urls work in isLocal 2026-02-20 01:38:39 +08:00
Adam
f2858a42ba chore: cleanup 2026-02-19 11:36:37 -06:00
Dax Raad
3c21735b35 refactor: migrate from Bun.Glob to npm glob package
Replace Bun.Glob usage with a new Glob utility wrapper around the npm 'glob' package.
This moves us off Bun-specific APIs toward standard Node.js compatible solutions.

Changes:
- Add new src/util/glob.ts utility module with scan(), scanSync(), and match()
- Default include option is 'file' (only returns files, not directories)
- Add symlink option (default: false) to control symlink following
- Migrate all 12 files using Bun.Glob to use the new Glob utility
- Add comprehensive tests for the glob utility

Breaking changes:
- Removed support for include: 'dir' option (use include: 'all' and filter manually)
- symlink now defaults to false (was true in most Bun.Glob usages)

Files migrated:
- src/util/log.ts
- src/util/filesystem.ts
- src/tool/truncation.ts
- src/session/instruction.ts
- src/storage/json-migration.ts
- src/storage/storage.ts
- src/project/project.ts
- src/cli/cmd/tui/context/theme.tsx
- src/config/config.ts
- src/tool/registry.ts
- src/skill/skill.ts
- src/file/ignore.ts
2026-02-19 12:34:18 -05:00
Adam
56dda4c98c chore: cleanup 2026-02-19 11:12:56 -06:00
Adam
6b8902e8b9 fix(app): navigate to last session on project nav 2026-02-19 11:12:56 -06:00
Frank
08a2d002b8 zen: gemini 3.1 pro 2026-02-19 11:43:14 -05:00
Dax
02a9495063 Remove use of Bun.file (#14215) 2026-02-19 11:32:32 -05:00
Adam
0fcba68d4c chore: cleanup 2026-02-19 09:00:43 -06:00
Adam
338393c016 fix(app): accordion styles 2026-02-19 09:00:43 -06:00
Adam
8ebdbe0ea2 fix(core): text files missclassified as binary 2026-02-19 07:32:58 -06:00
Adam
38f7071da9 chore: cleanup 2026-02-19 07:32:58 -06:00
Brendan Allan
d2d5f3c04b app: fix typecheck 2026-02-19 21:27:44 +08:00
Brendan Allan
885d71636f desktop: fetch defaultServer at top level 2026-02-19 21:14:59 +08:00
Adam
d07f09925f fix(app): terminal rework (#14217) 2026-02-19 06:35:14 -06:00
opencode-agent[bot]
c7b35342dd chore: update nix node_modules hashes 2026-02-19 06:40:44 +00:00
Aiden Cline
308e500832 tweak: bake in the aws and google auth pkgs (#14241) 2026-02-19 00:31:33 -06:00
opencode-agent[bot]
4b878f6aeb chore: generate 2026-02-19 05:19:18 +00:00
Ariane Emory
1893473148 fix: token substitution in OPENCODE_CONFIG_CONTENT (alternate take) (#14047) 2026-02-18 23:18:24 -06:00
Dax Raad
3a416f6f33 sdk: fix nested exports transformation in publish script
The publish script now recursively transforms export paths to handle nested export objects. This ensures all SDK entry points are correctly mapped to their compiled dist/ locations and type definitions when publishing to npm.
2026-02-18 20:43:50 -05:00
Dax Raad
11a37834c2 tui: ensure onExit callback fires after terminal output is written 2026-02-18 20:43:50 -05:00
Brendan Allan
d620455531 app: deduplicate allServers list 2026-02-19 09:34:23 +08:00
Dax Raad
568eccb4c6 Revert: all refactor commits migrating from Bun.file() to Filesystem module 2026-02-18 19:41:14 -05:00
Dax
3a07dd8d96 refactor: migrate src/project/project.ts from Bun.file() to Filesystem/stat modules (#14126) 2026-02-18 19:37:10 -05:00
David Hill
802ccd3788 tweak(ui): rotate collapsible chevron icon 2026-02-19 00:35:12 +00:00
David Hill
6042785c57 tweak(ui): rtl-truncate edited file paths 2026-02-19 00:32:12 +00:00
David Hill
5d8664c13e tweak(app): adjust session turn horizontal padding 2026-02-19 00:32:12 +00:00
David Hill
3d0f24067c tweak(app): tighten prompt dock padding 2026-02-19 00:32:12 +00:00
David Hill
44049540b0 tweak(ui): add open-file tooltip icon 2026-02-19 00:32:12 +00:00
David Hill
40f00ccc1c tweak(ui): use chevron icons for review diff rows 2026-02-19 00:32:12 +00:00
David Hill
a301051263 tweak(ui): tighten review diff file info gap 2026-02-19 00:32:12 +00:00
David Hill
fd61be4078 tweak(ui): show added diff counts in review 2026-02-19 00:32:12 +00:00
David Hill
4a8bdc3c75 tweak(ui): group edited files list styling 2026-02-19 00:32:12 +00:00
David Hill
9c7629ce61 Update oc-2.json 2026-02-19 00:32:12 +00:00
David Hill
ba53c56a21 tweak(ui): combine diffs in review into one group 2026-02-19 00:32:12 +00:00
Dax
14c0989411 refactor: migrate src/tool/read.ts from Bun.file() to Filesystem module (#14118) 2026-02-18 19:29:11 -05:00
Dax
36bc07a5af refactor: migrate src/tool/write.ts from Bun.file() to Filesystem module (#14119) 2026-02-18 19:27:11 -05:00
Dax
270b807cdf refactor: migrate src/tool/edit.ts from Bun.file() to Filesystem module (#14120) 2026-02-18 19:26:45 -05:00
Dax
bd52ce5640 refactor: migrate remaining tool files from Bun.file() to Filesystem/stat modules (#14121) 2026-02-18 19:24:21 -05:00
Dax
a624871ccd refactor: migrate src/storage/storage.ts from Bun.file()/Bun.write() to Filesystem module (#14122) 2026-02-18 19:21:21 -05:00
Dax
819d09e64e refactor: migrate src/storage/json-migration.ts from Bun.file() to Filesystem module (#14123) 2026-02-18 19:20:40 -05:00
Dax
9e6cb89101 refactor: migrate src/mcp/auth.ts from Bun.file()/Bun.write() to Filesystem module (#14125) 2026-02-18 19:20:16 -05:00
Dax
a8347c3762 refactor: migrate src/storage/db.ts from Bun.file() to statSync (#14124) 2026-02-18 19:20:03 -05:00
Dax
57b63ea83d refactor: migrate src/session/prompt.ts from Bun.file() to Filesystem/stat modules (#14128) 2026-02-19 00:18:05 +00:00
Ryan Vogel
c162074888 chore: skip PR standards checks for PRs created before Feb 18 2026 6PM EST (#14208) 2026-02-18 18:16:43 -05:00
Aiden Cline
088eac9d4e fix: opencode run crashing, and show errored tool calls in output (#14206) 2026-02-18 17:13:01 -06:00
Dax
5fe237a3fd refactor: migrate src/skill/discovery.ts from Bun.file()/Bun.write() to Filesystem module (#14133) 2026-02-18 23:10:24 +00:00
Dax
ae398539c5 refactor: migrate src/session/instruction.ts from Bun.file() to Filesystem module (#14130) 2026-02-18 18:09:45 -05:00
Dax
359360ad86 refactor: migrate src/provider/provider.ts from Bun.file() to Filesystem module (#14132) 2026-02-18 18:08:48 -05:00
Dax
5d12eb9528 refactor: migrate src/shell/shell.ts from Bun.file() to statSync (#14134) 2026-02-18 22:55:50 +00:00
Dax
6fb4f2a7a5 refactor: migrate src/cli/cmd/tui/thread.ts from Bun.file() to Filesystem module (#14135) 2026-02-18 17:28:41 -05:00
Dax
48dfa45a9a refactor: migrate src/util/log.ts from Bun.file() to Node.js fs module (#14136) 2026-02-18 17:28:08 -05:00
Dax
97520c827e refactor: migrate src/provider/models.ts from Bun.file()/Bun.write() to Filesystem module (#14131) 2026-02-18 17:26:13 -05:00
Dax
b75a89776d refactor: migrate src/lsp/client.ts from Bun.file() to Filesystem module (#14137) 2026-02-18 17:22:06 -05:00
opencode-agent[bot]
b909679367 chore: generate 2026-02-18 22:21:17 +00:00
Ryan Vogel
639d1dd8fe chore: add compliance checks for issues and PRs with recheck on edit (#14170) 2026-02-18 17:20:23 -05:00
Luke Parker
7033b4d0a8 fix(win32): Sidecar spawning a window (#14197) 2026-02-19 08:18:15 +10:00
Eduardo Bellido Bellido
87c16374aa fix(lsp): use HashiCorp releases API for installing terraform-ls (#14200) 2026-02-18 16:11:57 -06:00
Dax
d366a1430f refactor: migrate src/lsp/server.ts from Bun.file()/Bun.write() to Filesystem module (#14138) 2026-02-18 21:41:07 +00:00
David Hill
cfea5c73de tweak(app): delay prompt mode toggle tooltip 2026-02-18 21:33:14 +00:00
David Hill
2589eb207f tweak(app): shorten prompt mode toggle tooltips 2026-02-18 21:33:14 +00:00
David Hill
ec7c72da3f tweak(ui): restyle reasoning blocks 2026-02-18 21:33:14 +00:00
Dax
a4b36a72ad refactor: migrate src/file/time.ts from Bun.file() to stat (#14141) 2026-02-18 21:22:08 +00:00
Dax
e37a9081a6 refactor: migrate src/cli/cmd/session.ts from Bun.file() to statSync (#14144) 2026-02-18 16:20:58 -05:00
Dax
a2469d933e refactor: migrate src/acp/agent.ts from Bun.file() to Filesystem module (#14139) 2026-02-18 16:14:20 -05:00
Dax
3cde93bf2d refactor: migrate src/auth/index.ts from Bun.file()/Bun.write() to Filesystem module (#14140) 2026-02-18 16:13:50 -05:00
Dax
898bcdec87 refactor: migrate src/cli/cmd/agent.ts from Bun.file()/Bun.write() to Filesystem module (#14142) 2026-02-18 21:08:01 +00:00
Dax
d5971e2da5 refactor: migrate src/cli/cmd/import.ts from Bun.file() to Filesystem module (#14143) 2026-02-18 21:07:32 +00:00
David Hill
c71f4d4847 Update oc-2.json 2026-02-18 20:32:54 +00:00
opencode-agent[bot]
dec7827548 chore: generate 2026-02-18 20:19:27 +00:00
David Hill
7faa8cb110 tweak(ui): reduce review panel padding 2026-02-18 20:18:17 +00:00
David Hill
d8a4a125c0 Update oc-2.json 2026-02-18 20:18:17 +00:00
David Hill
50923f06f1 tweak(ui): remove pressed scale for secondary buttons 2026-02-18 20:18:17 +00:00
David Hill
ba919fb619 tweak(ui): shrink review expand/collapse width 2026-02-18 20:18:17 +00:00
David Hill
47b4de3531 tweak(ui): tighten review header action spacing 2026-02-18 20:18:17 +00:00
David Hill
bb6d1d502f tweak(ui): adjust review diff style hover radius 2026-02-18 20:18:17 +00:00
David Hill
31e964e7cf Update oc-2.json 2026-02-18 20:18:17 +00:00
David Hill
06b2304a5f tweak(ui): override for the radio group in the review 2026-02-18 20:18:17 +00:00
David Hill
1b67339e4d Update radio-group.css 2026-02-18 20:18:17 +00:00
David Hill
1571246ba8 tweak(ui): use default cursor for segmented control 2026-02-18 20:18:17 +00:00
David Hill
d730d8be01 tweak(ui): shrink review diff style toggle 2026-02-18 20:18:17 +00:00
David Hill
e42cc85112 Update oc-2.json 2026-02-18 20:18:17 +00:00
David Hill
c7a79f1877 Update icon-button.css 2026-02-18 20:18:17 +00:00
David Hill
431f5347af tweak(ui): search button style 2026-02-18 20:18:17 +00:00
David Hill
1ed4a98233 tweak(ui): remove pressed transition for secondary buttons 2026-02-18 20:18:17 +00:00
David Hill
db4ff89579 Update oc-2.json 2026-02-18 20:18:17 +00:00
David Hill
2f56761060 tweak(ui): expanded color state on titlebar buttons 2026-02-18 20:18:17 +00:00
David Hill
09286ccae0 tweak(ui): oc-2 theme updates 2026-02-18 20:18:17 +00:00
David Hill
4e959849f6 tweak(ui): hover and active styles for filetree tabs 2026-02-18 20:18:17 +00:00
David Hill
3690cafeb8 tweak(ui): hover and active styles for title bar buttons 2026-02-18 20:18:17 +00:00
David Hill
bcca253dec tweak(ui): hover and active styles for title bar buttons 2026-02-18 20:18:17 +00:00
David Hill
6d69ad5574 tweak(ui): update oc-2 secondary button colors 2026-02-18 20:18:17 +00:00
David Hill
1f9be63e96 tweak(ui): use weak border and base icon color for secondary 2026-02-18 20:18:17 +00:00
David Hill
0873908030 tweak(ui): theme color updates 2026-02-18 20:18:17 +00:00
David Hill
4db2d94854 tweak(ui): shrink filetree tab height 2026-02-18 20:18:17 +00:00
David Hill
e5d52e4eb5 tweak(ui): align pill tabs pressed background 2026-02-18 20:18:16 +00:00
David Hill
f20c0bffd3 tweak(ui): unify titlebar expanded button background 2026-02-18 20:18:16 +00:00
David Hill
9110e6a2a7 tweak(ui): share button border 2026-02-18 20:18:16 +00:00
David Hill
0888c02379 tweak(ui): file tree background color 2026-02-18 20:18:16 +00:00
David Hill
24ce49d9d7 fix(ui): add previous smoke colors 2026-02-18 20:18:16 +00:00
David Hill
5d69f00282 button style tweaks 2026-02-18 20:18:16 +00:00
David Hill
12016c8eb4 oc-2 theme init 2026-02-18 20:18:16 +00:00
David Hill
d6331cf792 Update colors.css 2026-02-18 20:18:16 +00:00
opencode-agent[bot]
2d7c9c9692 chore: generate 2026-02-18 20:15:14 +00:00
Helge Tesdal
1aa18c6cd6 feat(plugin): pass sessionID and callID to shell.env hook input (#13662) 2026-02-18 14:14:18 -06:00
Adam
de25703e9d fix(app): terminal cross-talk (#14184) 2026-02-18 13:56:05 -06:00
Adam
1133d87be0 chore: cleanup 2026-02-18 13:38:51 -06:00
Adam
42aa28d512 chore: cleanup (#14181) 2026-02-18 13:23:20 -06:00
opencode-agent[bot]
c6bd320003 chore: update nix node_modules hashes 2026-02-18 19:08:12 +00:00
Frank
24a9841322 zen: update sst version 2026-02-18 13:54:23 -05:00
Dax
8bf06cbcc1 refactor: migrate src/global/index.ts from Bun.file() to Filesystem module (#14146) 2026-02-18 13:25:18 -05:00
OpeOginni
be2e6f1926 fix(opencode): update pasteImage to only increment count when the previous attachment is an image too (#14173) 2026-02-18 12:09:09 -06:00
Adam
72c12d59af chore: cleanup 2026-02-18 11:55:08 -06:00
Adam
8408e4702e chore: cleanup 2026-02-18 11:48:25 -06:00
Adam
ef14f64f9e chore: cleanup 2026-02-18 11:48:25 -06:00
Adam
3f60a6c2a4 chore: cleanup 2026-02-18 11:48:25 -06:00
Matt Silverlock
d447b7694a fix(github): emit PROMPT_TOO_LARGE error on context overflow (#14166) 2026-02-18 11:45:27 -06:00
Dax
5638b782c5 refactor: migrate editor.ts from Bun.file()/Bun.write() to Filesystem module (#14149) 2026-02-18 17:44:33 +00:00
Dax
eb3f337695 refactor: migrate clipboard.ts from Bun.file() to Filesystem module (#14148) 2026-02-18 12:38:44 -05:00
Dax
c88ff3c08b refactor: migrate src/bun/index.ts from Bun.file()/Bun.write() to Filesystem module (#14147) 2026-02-18 12:38:30 -05:00
Dax
e0e8b94384 refactor: migrate uninstall.ts from Bun.file()/Bun.write() to Filesystem module (#14150) 2026-02-18 12:35:55 -05:00
Dax
8f4a72c57a refactor: migrate config/markdown.ts from Bun.file() to Filesystem module (#14151) 2026-02-18 12:35:32 -05:00
Dax
ef155f3766 refactor: migrate file/index.ts from Bun.file() to Filesystem module (#14152) 2026-02-18 12:33:20 -05:00
Dax
82a323ef70 refactor: migrate cli/cmd/github.ts from Bun.write() to Filesystem module (#14154) 2026-02-18 12:33:05 -05:00
Dax
a500eaa2d4 refactor: migrate format/formatter.ts from Bun.file() to Filesystem module (#14153) 2026-02-18 12:31:35 -05:00
Dax Raad
b714bb21d2 ci: switch to standard GitHub cache action for Bun dependencies 2026-02-18 12:24:17 -05:00
Dax
472d01fbaf refactor: migrate cli/cmd/run.ts from Bun.file() to Filesystem/stat modules (#14155) 2026-02-18 17:22:46 +00:00
Dax Raad
a5c15a23e4 core: allow readJson to be called without explicit type parameter
Added default type parameter 'any' to readJson<T> so users can call it without specifying a type when they don't need strict typing. This reduces boilerplate for quick JSON reads where type safety isn't required.
2026-02-18 12:21:25 -05:00
Dax
3d189b42a3 refactor: migrate file/ripgrep.ts from Bun.file()/Bun.write() to Filesystem module (#14159) 2026-02-18 12:10:42 -05:00
opencode-agent[bot]
91a3ee642d chore: update nix node_modules hashes 2026-02-18 17:08:31 +00:00
Dax
37b24f4870 refactor: migrate index.ts from Bun.file() to Filesystem module (#14160) 2026-02-18 12:03:24 -05:00
Alexander Abramov
38572b8175 feat: add Julia language server support (#14129)
Co-authored-by: Alexander Abramov <abramov231@gmail.com>
2026-02-18 11:02:29 -06:00
Aiden Cline
fc1addb8f4 ignore: tweak contributing md (#14168) 2026-02-18 11:01:59 -06:00
Vladimir Glafirov
83b7d8e04c feat: GitLab Duo - bump gitlab-ai-provider to 3.6.0 (adds Sonnet 4.6) (#14115) 2026-02-18 10:58:38 -06:00
Mike Harris
d27dbfe062 fix(cli): session list --max-count not honored, shows too few sessions (#14162) 2026-02-18 10:56:37 -06:00
Brendan Allan
f8904e3972 desktop: handle sidecar key in projectsKey 2026-02-19 00:05:50 +08:00
Brendan Allan
4a5823562c desktop: fix isLocal 2026-02-19 00:03:08 +08:00
opencode-agent[bot]
3aaf29b693 chore: update nix node_modules hashes 2026-02-18 15:41:20 +00:00
Dax
6b29896a35 feat: Add centralized filesystem module for Bun.file migration (#14117) 2026-02-18 15:30:52 +00:00
Brendan Allan
1bb8574179 app: refactor server management backend (#13813) 2026-02-18 23:03:24 +08:00
Adam
2611c35acc fix(app): lower threshold for diff hiding 2026-02-18 08:40:05 -06:00
Adam
00c238777a chore: cleanup (#14113) 2026-02-18 08:26:15 -06:00
Dax Raad
e4b548fa76 docs: add policy about AI-generated security reports
We receive a large number of AI-generated security reports and don't have the resources to review them all. This policy clarifies that such submissions will result in an automatic ban to protect our maintainers' time.
2026-02-18 09:15:18 -05:00
Adam
e132dd2c70 chore: cleanup 2026-02-18 07:22:36 -06:00
David Hill
fbe9669c57 fix: use group-hover for file tree icon color swap at all nesting levels 2026-02-18 13:20:02 +00:00
Adam
c34ad7223a chore: cleanup 2026-02-18 07:12:54 -06:00
David Hill
cc86a64bb5 tui: simplify mode toggle icon styling
Use consistent strong color for active mode icons instead of different
colors for shell vs normal mode, making the active state more visually
clear to users.
2026-02-18 12:35:28 +00:00
Adam
3394402aef chore: cleanup 2026-02-18 06:32:35 -06:00
Brendan Allan
6cd3a59022 desktop: cleanup 2026-02-18 16:24:28 +08:00
Brendan Allan
5aeb305344 desktop: temporarily disable wsl 2026-02-18 16:10:07 +08:00
Caleb Norton
6eb043aedb ci: allow commits on top of beta PRs (#11924) 2026-02-18 00:20:05 -06:00
Salam Elbilig
e96f6385c2 fix(opencode): fix Clojure syntax highlighting (#13453)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-18 00:18:18 -06:00
Jérôme Benoit
1109a282e0 ci: add nix-eval workflow for cross-platform flake evaluation (#12175) 2026-02-18 00:03:37 -06:00
Aiden Cline
25f3eef957 fix: ensure explore subagent has external_directory perm set to ask instead of auto denying (#14060) 2026-02-17 20:16:55 -06:00
Aiden Cline
0ca75544ab fix: dont autoload kilo (#14052) 2026-02-17 18:42:18 -06:00
opencode-agent[bot]
572a037e5d chore: generate 2026-02-17 23:53:22 +00:00
RAMA
ad92181fa7 feat: add Kilo as a native provider (#13765) 2026-02-17 17:52:21 -06:00
legao
c56f4aa5d8 refactor: simplify redundant ternary in updateMessage (#13954) 2026-02-17 17:40:14 -06:00
opencode-agent[bot]
a344a766fd chore: generate 2026-02-17 23:36:08 +00:00
Aiden Cline
bca793d064 ci: ensure triage adds acp label (#14039) 2026-02-17 17:34:47 -06:00
Dax Raad
ad3c192837 tui: exit cleanly without hanging after session ends
- Force process exit after TUI thread completes to prevent lingering processes
- Add 5-second timeout to worker shutdown to prevent indefinite hangs during cleanup
2026-02-17 17:56:39 -05:00
Anton Volkov
5512231ca8 fix(tui): style scrollbox for permission and sidebar (#12752) 2026-02-17 16:24:01 -06:00
Anton Volkov
bad394cd49 chore: remove leftover patch (#13749) 2026-02-17 16:22:38 -06:00
Aiden Cline
3b97580621 tweak: ensure read tool uses fs/promises for all paths (#14027) 2026-02-17 16:05:22 -06:00
jackarch-2
cb88fe26aa chore: add missing newline (#13992) 2026-02-17 16:04:58 -06:00
Adam
e345b89ce5 fix(app): better tool call batching 2026-02-17 15:57:50 -06:00
Adam
26c7b240ba chore: cleanup 2026-02-17 15:54:59 -06:00
Adam
d327a2b1cf chore(app): use radio group in prompt input (#14025) 2026-02-17 15:53:38 -06:00
Aiden Cline
c1b03b728a fix: make read tool more mem efficient (#14009) 2026-02-17 15:36:45 -06:00
opencode-agent[bot]
2a2437bf22 chore: generate 2026-02-17 21:23:23 +00:00
Nathan Anderson
4ccb82e81a feat: surface plugin auth providers in the login picker (#13921)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-02-17 15:21:49 -06:00
David Hill
92912219df tui: simplify prompt mode toggle icon colors via CSS and tighten message timeline padding 2026-02-17 20:10:16 +00:00
Adam
bab3124e8b fix(app): prompt input quirks 2026-02-17 13:10:43 -06:00
Frank
7a66ec6bc9 zen: sonnet 4.6 2026-02-17 14:10:21 -05:00
Adam
3a505b2691 fix(app): virtualizer getting wrong scroll root 2026-02-17 12:57:40 -06:00
Adam
20f43372f6 fix(app): terminal disconnect and resync (#14004) 2026-02-17 12:54:28 -06:00
Eduardo Gomes
fb79dd7bf8 fix: Invalidate oauth credentials when oauth provider says so (#14007)
Co-authored-by: Eduardo Gomes <egomes@cloudflare.com>
2026-02-17 12:46:26 -06:00
Brendan Allan
4025b655a4 desktop: replicate tauri-plugin-shell logic (#13986) 2026-02-18 02:40:52 +08:00
David Hill
7379903568 tui: improve modified file visibility and button spacing
- Replace warning yellow with distinct orange color for modified files in git diff indicators
- Increase button padding for better visual balance in session header and status popover
2026-02-17 18:39:21 +00:00
David Hill
a685e7a805 tui: show monochrome file icons by default in tree view, revealing colors on hover to reduce visual clutter and help users focus on code content 2026-02-17 18:23:04 +00:00
David Hill
ce7484b4f5 tui: fix share button text styling to use consistent 12px regular font weight 2026-02-17 17:55:55 +00:00
David Hill
0bc1dcbe1b tweak(ui): update icon transparency 2026-02-17 17:52:29 +00:00
David Hill
a69b339baf fix(ui): use icon-strong-base for active titlebar icon buttons 2026-02-17 17:51:49 +00:00
David Hill
26f835cdd2 tweak(ui): icon-interactive-base color change dark mode 2026-02-17 17:43:37 +00:00
David Hill
bd3d1413fd tui: add warning icon to permission requests for better visibility
Adds a visual warning indicator to permission request dialogs to make

them more noticeable and help users understand when the agent needs

approval to use tools. Also improves the layout with consistent

spacing and icon alignment.
2026-02-17 17:43:37 +00:00
David Hill
2c17a980ff refactor(ui): extract dock prompt shell 2026-02-17 17:43:37 +00:00
David Hill
b784c923a8 tweak(ui): bump button heights and align permission prompt layout 2026-02-17 17:43:37 +00:00
Aiden Cline
ea96f898c0 ci: rm remap for jlongster since he is in org now (#14000) 2026-02-17 11:08:35 -06:00
Caleb Norton
47435f6e17 fix: don't fetch models.dev on completion (#13997) 2026-02-17 10:41:03 -06:00
Alex Carpenter
df59d1412b fix: Homepage video section layout shift (#13987) 2026-02-17 21:22:47 +05:30
Filip
46739ca7cd fix(app): ui flashing when switching tabs (#13978) 2026-02-17 21:19:20 +05:30
Chris Yang
d055c1cad6 fix(desktop): avoid sidecar health-check timeout on shell startup (#13925)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-02-17 15:34:16 +00:00
David Hill
adfbfe350d tui: increase prompt mode toggle height for better clickability 2026-02-17 15:28:35 +00:00
David Hill
652a776554 ui: add clearer 'Copy response' tooltip label for text parts 2026-02-17 15:19:30 +00:00
David Hill
1d78100f63 tweak(ui): allow full-width user message meta
Moves the user message meta row out of the bubble width constraints and truncates long metadata while keeping the timestamp visible with consistent middot spacing.
2026-02-17 15:16:07 +00:00
David Hill
57a5d5fd34 tweak(ui): show assistant response meta on hover
Adds hover-only metadata after the assistant copy icon showing agent, provider, model, and response duration.
2026-02-17 15:16:07 +00:00
David Hill
14684d8e75 tweak(ui): refine user message hover meta
Moves the interrupted state into the user message hover metadata and updates the copy tooltip to 'Copy message'.
2026-02-17 15:16:07 +00:00
David Hill
2cac848823 tweak(ui): use provider catalog names
Renders provider and model display names from the provider list instead of raw IDs in user message hover metadata.
2026-02-17 15:16:07 +00:00
David Hill
5a3e0ef13a tweak(ui): show user message meta on hover
Adds a hover-only metadata line under user messages showing agent, provider, model, and timestamp for quicker context.
2026-02-17 15:16:07 +00:00
opencode-agent[bot]
7ed4499748 chore: generate 2026-02-17 14:43:42 +00:00
Filip
4d5e86d8a5 feat(desktop): more e2e tests (#13975) 2026-02-17 08:42:50 -06:00
David Hill
222b6cda96 tweak(ui): update magnifying-glass icon
Replace the magnifying-glass glyph with a 16px viewBox variant and keep default 1px stroke; adjust the titlebar search to render the icon at 16x16.
2026-02-17 14:37:44 +00:00
David Hill
8e243c6500 tweak(app): tighten titlebar action padding
Use pr-2 for the status and fallback copy-path actions, and tighten the copy icon/text gap to 1.5.
2026-02-17 14:37:44 +00:00
David Hill
98f3ff6273 tweak(app): refine titlebar search and open padding
Ensure the titlebar search placeholder truncates cleanly and left-aligns; match the fallback copy-path button left padding to the open action.
2026-02-17 14:37:44 +00:00
David Hill
ce08442732 tweak(ui): center titlebar search and soften keybind
Mount the titlebar search in the center area and tune its sizing/spacing; use regular weight for the keybind pill text.
2026-02-17 14:37:44 +00:00
David Hill
8fcfbd697a tweak(app): align titlebar search text size
Use the same 12px text style for the titlebar search placeholder as the status and open actions.
2026-02-17 14:37:44 +00:00
David Hill
a8669aba8f tweak(app): match titlebar active bg to hover
Use the ghost hover background for active/expanded titlebar actions and tighten titlebar popover gutters to 4px.
2026-02-17 14:37:44 +00:00
David Hill
d31e9cff6a tweak(app): use weak borders in titlebar actions
Use border-border-weak-base for the titlebar status and open actions (including the open button divider) and adjust the English copy-path label casing.
2026-02-17 14:37:44 +00:00
David Hill
0cb11c2412 tweak(app): reduce titlebar right padding
Use pr-2 (instead of pr-6) on the titlebar right section when not on Windows.
2026-02-17 14:37:44 +00:00
David Hill
9b1d7047d4 tweak(app): keep file tree toggle visible
Always show the titlebar file tree button (and space the right-side icon buttons at 4px) so it stays accessible regardless of the review panel state.
2026-02-17 14:37:44 +00:00
opencode-agent[bot]
703d634744 chore: generate 2026-02-17 13:45:58 +00:00
David Hill
e273a31e70 tweak(ui): icon button spacing 2026-02-17 13:44:59 +00:00
Adam
277c68d8e5 chore: app polish (#13976)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-02-17 07:34:02 -06:00
Adam
10985671ad feat(app): session timeline/turn rework (#13196)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-02-17 07:16:23 -06:00
Shoubhit Dash
3dfbb70593 fix(app): recover state after sse reconnect and harden sse streams (#13973) 2026-02-17 07:10:39 -06:00
David Hill
07947bab7d tweak(tui): new session banner with logo and details (#13970) 2026-02-17 07:43:55 -05:00
opencode-agent[bot]
4eed55973f chore: generate 2026-02-17 12:08:03 +00:00
Minung Han
6e984378d7 fix(docs): correct reversed meaning in Korean plugins logging section (#13945) 2026-02-17 06:07:09 -06:00
chenmi
4fd3141ab5 docs: improve zh-cn and zh-tw documentation translations (#13942) 2026-02-17 06:06:39 -06:00
vynn
8d0a303af4 docs(ko): improve Korean translation accuracy and clarity in Zen docs (#13951) 2026-02-17 06:05:37 -06:00
Ganesh
0186a85063 fix(app): keep Escape handling local to prompt input on macOS desktop (#13963) 2026-02-17 06:04:11 -06:00
Aiden Cline
ed4e4843c2 ci: update triage workflow (#13944) 2026-02-17 01:05:56 -06:00
Frank
a93a1b93e1 wip: zen 2026-02-17 01:32:57 -05:00
Frank
ace63b3ddb zen: glm 5 free 2026-02-17 01:12:13 -05:00
Brendan Allan
d338bd528c Hide server CLI on windows (#13936) 2026-02-17 12:43:25 +08:00
Goni Zahavy
ea2d089db0 ci: fixed missing if condition (#13934) 2026-02-17 12:42:55 +08:00
Goni Zahavy
4226097228 ci: fixed Rust cache for 'cargo install' in publish.yml (#13907) 2026-02-17 12:13:33 +08:00
Aiden Cline
e35a4131d0 core: keep message part order stable when files resolve asynchronously (#13915) 2026-02-16 18:45:11 -06:00
Goni Zahavy
0e669b6016 ci: use useblacksmith/stickydisk on linux runners only (#13909) 2026-02-16 18:27:04 -06:00
Goni Zahavy
9163611989 ci: fixed apt cache not working in publish.yml (#13897) 2026-02-16 17:31:38 -06:00
James Long
d93cefd47a fix(website): fix site in safari 18 (#13894) 2026-02-16 17:39:28 -05:00
Aiden Cline
a580fb47d2 tweak: drop ids from attachments in tools, assign them in prompt.ts instead (#13890) 2026-02-16 14:59:57 -06:00
ImmuneFOMO
9d3c81a683 feat(acp): add opt-in flag for question tool (#13562)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-02-16 14:20:41 -06:00
Zhiyuan Zheng
86e545a23e fix(opencode): ACP sessions never get LLM-generated titles (#13095) 2026-02-16 14:16:17 -06:00
Ariane Emory
b0afdf6ea4 feat(cli): add session delete command (#13571) 2026-02-16 14:15:34 -06:00
opencode
d8c25bfeb4 release: v1.2.6 2026-02-16 19:57:09 +00:00
Robert Schadek
160ba295a8 feat(opencode): add dfmt formatter support for D language files (#13867) 2026-02-16 13:14:35 -06:00
OpeOginni
16332a8583 fix(tui): make use of server dir path for file references in prompts (#13781) 2026-02-16 13:14:08 -06:00
Aiden Cline
ae6e85b2a4 ignore: rm random comment on opencode.jsonc 2026-02-16 13:09:39 -06:00
Dax
fdad823edc feat(cli): add db migrate command for JSON to SQLite migration (#13874) 2026-02-16 19:05:21 +00:00
Ryan Vogel
5cc1d6097e feat(cli): add --continue and --fork flags to attach command (#13879) 2026-02-16 13:45:00 -05:00
opencode-agent[bot]
8c1af9b445 chore: update nix node_modules hashes 2026-02-16 17:38:43 +00:00
Vladimir Glafirov
ef979ccfa8 fix: bump GitLab provider and auth plugin for mid-session token refresh (#13850) 2026-02-16 10:01:17 -06:00
Imanol Maiztegui
bb30e06855 fix (tui): Inaccurate tips (#13845) 2026-02-16 09:08:04 -05:00
Adam
b055f973df chore: cleanup 2026-02-16 07:58:18 -06:00
Rafi Khardalian
45fa5e7199 fix(core): remove unnecessary per-message title LLM calls (#13804) 2026-02-16 06:04:20 -06:00
Chujiang
3ebf27aab9 fix(docs): correct critical translation errors in Russian zen page (#13830) 2026-02-16 06:02:48 -06:00
Aiden Cline
1d041c8861 fix: google vertex var priority (#13816) 2026-02-16 02:41:52 -06:00
opencode-agent[bot]
089ab9defa chore: generate 2026-02-16 08:32:34 +00:00
Jhin Lee
f7708efa5b feat: add openai-compatible endpoint support for google-vertex provider (#10303)
Co-authored-by: BlueT - Matthew Lien - 練喆明 <BlueT@BlueT.org>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-02-16 02:31:48 -06:00
bnema
60807846a9 fix(desktop): normalize Linux Wayland/X11 backend and decoration policy (#13143)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-02-16 13:24:28 +08:00
dpuyosa
afd0716cbd feat(opencode): Add Venice support in temperature, topP, topK and smallOption (#13553) 2026-02-15 22:24:24 -06:00
Brendan Allan
920255e8c6 desktop: use process-wrap instead of manual job object (#13431) 2026-02-16 04:14:24 +00:00
opencode-agent[bot]
21e0778002 chore: generate 2026-02-15 22:31:40 +00:00
Pan Kaixin
d9363da9ee fix(website): correct zh-CN translation of proprietary terms in zen.mdx (#13734)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-15 16:30:47 -06:00
Salam Elbilig
9b23130ac4 feat(opencode): add cljfmt formatter support for Clojure files (#13426) 2026-02-15 15:21:57 -06:00
opencode
62a24c2dda release: v1.2.5 2026-02-15 18:49:52 +00:00
Alex Yaroshuk
3a3aa300bb feat(app): localize "free usage exceeded" error & "Add credits" clickable link (#13652) 2026-02-15 10:40:09 -06:00
Shane Bishop
cf50a289db fix(desktop): issue viewing new files opened from the file tree (#13689) 2026-02-15 09:48:40 -06:00
Shoubhit Dash
3c85cf4fac fix(app): only navigate prompt history at input boundaries (#13690) 2026-02-15 07:47:19 -06:00
Filip
878ddc6a0a fix(app): keybind [shift+tab] (#13695) 2026-02-15 07:46:56 -06:00
Denys
3761121728 docs: add Ukrainian README translation (#13697) 2026-02-15 07:46:19 -06:00
zerone0x
3aaa34be1e fix(desktop): focus window after update/relaunch (#13701) 2026-02-15 07:45:34 -06:00
Brandon Julio Thenaro
985c2a3d15 feat: Add GeistMono Nerd Font to available mono font options (#13720) 2026-02-15 07:44:21 -06:00
Aiden Cline
eb553f53ac fix: ensure sqlite migration logs to stderr instead of stdout (#13691) 2026-02-15 00:41:16 -06:00
472 changed files with 21286 additions and 11996 deletions

15
.github/TEAM_MEMBERS vendored Normal file
View File

@@ -0,0 +1,15 @@
adamdotdevin
Brendonovich
fwang
Hona
iamdavidhill
jayair
jlongster
kitlangton
kommander
MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
RhysSullivan
thdxr

View File

@@ -3,11 +3,13 @@ description: "Setup Bun with caching and install dependencies"
runs:
using: "composite"
steps:
- name: Mount Bun Cache
uses: useblacksmith/stickydisk@v1
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
path: ~/.bun
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -1,7 +1,29 @@
### Issue for this PR
Closes #
### Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
### What does this PR do?
Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
### How did you verify your code works?
### Screenshots / recordings
_If this is a UI change, please include a screenshot or recording._
### Checklist
- [ ] I have tested my changes locally
- [ ] I have not included unrelated changes in this PR
_If you do not follow this template your PR will be automatically rejected._

View File

@@ -2,10 +2,11 @@ name: duplicate-issues
on:
issues:
types: [opened]
types: [opened, edited]
jobs:
check-duplicates:
if: github.event.action == 'opened'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
@@ -34,7 +35,7 @@ jobs:
"webfetch": "deny"
}
run: |
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
Issue number: ${{ github.event.issue.number }}
@@ -115,3 +116,62 @@ jobs:
If you believe this was flagged incorrectly, please let a maintainer know.
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
recheck-compliance:
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Recheck compliance
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow"
},
"webfetch": "deny"
}
run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
Re-check whether the issue now follows our contributing guidelines and issue templates.
This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
3. Question - requires the Question field with real content
Additionally check:
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
- The issue has real content, not just template placeholder text left unchanged
- Bug reports should include some context about how to reproduce
- Feature requests should explain the problem or need
- We want to push for having the user provide system description & information
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
If the issue is NOW compliant:
1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance
2. Find and delete the previous compliance comment (the one containing <!-- issue-compliance -->) using: gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments --jq '.[] | select(.body | contains(\"<!-- issue-compliance -->\")) | .id' then delete it with: gh api -X DELETE repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments/{id}
3. Post a short comment thanking them for updating the issue.
If the issue is STILL not compliant:
Post a comment explaining what still needs to be fixed. Keep the needs:compliance label."

View File

@@ -1,46 +0,0 @@
name: nix-desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
workflow_dispatch:
jobs:
nix-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404-arm
- macos-15-intel
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L

95
.github/workflows/nix-eval.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: nix-eval
on:
push:
branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
nix-eval:
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Evaluate flake outputs (all systems)
run: |
set -euo pipefail
nix --version
echo "=== Flake metadata ==="
nix flake metadata
echo ""
echo "=== Flake structure ==="
nix flake show --all-systems
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
PACKAGES="opencode"
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
OPTIONAL_PACKAGES="desktop"
echo ""
echo "=== Evaluating packages for all systems ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for packages.$system.$pkg"
echo "$output"
exit 1
fi
done
done
echo ""
echo "=== Evaluating optional packages ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $OPTIONAL_PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::warning::Evaluation failed for packages.$system.$pkg"
echo "$output"
fi
done
done
echo ""
echo "=== Evaluating devShells for all systems ==="
for system in $SYSTEMS; do
printf "%s: " "$system"
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for devShells.$system.default"
echo "$output"
exit 1
fi
done
echo ""
echo "=== All evaluations passed ==="

View File

@@ -6,7 +6,7 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev]
branches: [dev, beta]
paths:
- "bun.lock"
- "package.json"

View File

@@ -6,17 +6,6 @@ on:
jobs:
check-duplicates:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
@@ -27,16 +16,31 @@ jobs:
with:
fetch-depth: 1
- name: Check team membership
id: team-check
run: |
LOGIN="${{ github.event.pull_request.user.login }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
echo "is_team=true" >> "$GITHUB_OUTPUT"
echo "Skipping: $LOGIN is a team member or bot"
else
echo "is_team=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
if: steps.team-check.outputs.is_team != 'true'
uses: ./.github/actions/setup-bun
- name: Install dependencies
if: steps.team-check.outputs.is_team != 'true'
run: bun install
- name: Install opencode
if: steps.team-check.outputs.is_team != 'true'
run: curl -fsSL https://opencode.ai/install | bash
- name: Build prompt
if: steps.team-check.outputs.is_team != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -53,6 +57,7 @@ jobs:
} > pr_info.txt
- name: Check for duplicate PRs
if: steps.team-check.outputs.is_team != 'true'
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,19 +6,9 @@ on:
jobs:
check-standards:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check PR standards
@@ -26,6 +16,30 @@ jobs:
with:
script: |
const pr = context.payload.pull_request;
const login = pr.user.login;
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
const cutoff = new Date('2026-02-19T00:00:00Z');
const prCreated = new Date(pr.created_at);
if (prCreated < cutoff) {
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
return;
}
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
const title = pr.title;
async function addLabel(label) {
@@ -137,3 +151,201 @@ jobs:
await removeLabel('needs:issue');
console.log('PR meets all standards');
check-compliance:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check PR template compliance
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const login = pr.user.login;
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
const cutoff = new Date('2026-02-19T00:00:00Z');
const prCreated = new Date(pr.created_at);
if (prCreated < cutoff) {
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
return;
}
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
const body = pr.body || '';
const title = pr.title;
const isDocsOrRefactor = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
const issues = [];
// Check: template sections exist
const hasWhatSection = /### What does this PR do\?/.test(body);
const hasTypeSection = /### Type of change/.test(body);
const hasVerifySection = /### How did you verify your code works\?/.test(body);
const hasChecklistSection = /### Checklist/.test(body);
const hasIssueSection = /### Issue for this PR/.test(body);
if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) {
issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).');
}
// Check: "What does this PR do?" has real content (not just placeholder text)
if (hasWhatSection) {
const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/);
const whatContent = whatMatch ? whatMatch[1].trim() : '';
const placeholder = 'Please provide a description of the issue';
const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20;
if (!whatContent || onlyPlaceholder) {
issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.');
}
}
// Check: at least one "Type of change" checkbox is checked
if (hasTypeSection) {
const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/);
const typeContent = typeMatch ? typeMatch[1] : '';
const hasCheckedBox = /- \[x\]/i.test(typeContent);
if (!hasCheckedBox) {
issues.push('No "Type of change" checkbox is checked. Please select at least one.');
}
}
// Check: issue reference (skip for docs/refactor)
if (!isDocsOrRefactor && hasIssueSection) {
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : '';
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
if (!hasIssueRef) {
issues.push('No issue referenced. Please add `Closes #<number>` linking to the relevant issue.');
}
}
// Check: "How did you verify" has content
if (hasVerifySection) {
const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/);
const verifyContent = verifyMatch ? verifyMatch[1].trim() : '';
if (!verifyContent) {
issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.');
}
}
// Check: checklist boxes are checked
if (hasChecklistSection) {
const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/);
const checklistContent = checklistMatch ? checklistMatch[1] : '';
const unchecked = (checklistContent.match(/- \[ \]/g) || []).length;
const checked = (checklistContent.match(/- \[x\]/gi) || []).length;
if (checked < 2) {
issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.');
}
}
// Helper functions
async function addLabel(label) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label]
});
}
async function removeLabel(label) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label
});
} catch (e) {}
}
const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance');
if (issues.length > 0) {
// Non-compliant
if (!hasComplianceLabel) {
await addLabel('needs:compliance');
}
const marker = '<!-- issue-compliance -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const existing = comments.find(c => c.body.includes(marker));
const body_text = `${marker}
This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md).
**What needs to be fixed:**
${issues.map(i => `- ${i}`).join('\n')}
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
If you believe this was flagged incorrectly, please let a maintainer know.`;
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body_text
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: body_text
});
}
console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`);
} else if (hasComplianceLabel) {
// Was non-compliant, now fixed
await removeLabel('needs:compliance');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const marker = '<!-- issue-compliance -->';
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id
});
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:'
});
console.log(`PR #${pr.number} is now compliant, label removed`);
} else {
console.log(`PR #${pr.number} is compliant`);
}

View File

@@ -137,7 +137,7 @@ jobs:
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt-
@@ -145,8 +145,10 @@ jobs:
- name: install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo chmod -R a+rw ~/apt-cache
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -169,13 +171,23 @@ jobs:
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Resolve tauri portable SHA
if: contains(matrix.settings.host, 'ubuntu')
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch
uses: taiki-e/cache-cargo-install-action@v3
if: contains(matrix.settings.host, 'ubuntu')
run: |
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
echo "Installed tauri-cli version:"
cargo tauri --version
with:
tool: tauri-cli
git: https://github.com/tauri-apps/tauri
# branch: feat/truly-portable-appimage
rev: ${{ env.TAURI_PORTABLE_SHA }}
- name: Show tauri-cli version
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a

View File

@@ -359,6 +359,7 @@ opencode serve --hostname 0.0.0.0 --port 4096
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
opencode session [command]
opencode session list
opencode session delete <sessionID>
opencode stats
opencode uninstall
opencode upgrade
@@ -598,6 +599,7 @@ OPENCODE_EXPERIMENTAL_MARKDOWN
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
OPENCODE_EXPERIMENTAL_OXFMT
OPENCODE_EXPERIMENTAL_PLAN_MODE
OPENCODE_ENABLE_QUESTION_TOOL
OPENCODE_FAKE_VCS
OPENCODE_GIT_BASH_PATH
OPENCODE_MODEL

View File

@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
model: opencode/minimax-m2.5
color: "#44BA81"
tools:
"*": false
@@ -12,6 +12,8 @@ You are a triage agent responsible for triaging github issues.
Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules.
## Labels
### windows
@@ -43,12 +45,34 @@ Desktop app issues:
**Only** add if the issue explicitly mentions nix.
If the issue does not mention nix, do not add nix.
If the issue mentions nix, assign to `rekram1-node`.
#### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Examples:
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
#### acp
If the issue mentions acp support, assign acp label.
#### docs
Add if the issue requests better documentation or docs updates.
@@ -66,13 +90,51 @@ TUI issues potentially caused by our underlying TUI library:
When assigning to people here are the following rules:
adamdotdev:
ONLY assign adam if the issue will have the "desktop" label.
Desktop / Web:
Use for desktop-labeled issues only.
fwang:
ONLY assign fwang if the issue will have the "zen" label.
- adamdotdevin
- iamdavidhill
- Brendonovich
- nexxeln
jayair:
ONLY assign jayair if the issue will have the "docs" label.
Zen:
ONLY assign if the issue will have the "zen" label.
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
- fwang
- MrMushrooooom
TUI (`packages/opencode/src/cli/cmd/tui/...`):
- thdxr for TUI UX/UI product decisions and interaction flow
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
Core (`packages/opencode/...`, excluding TUI subtree):
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
- rekram1-node for harness issues, provider issues, and other bug-squashing
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
Docs:
- R44VC0RP
Windows:
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If title + body does not contain "zen", do not add the "zen" label
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `rekram1-node`
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
ACP:
- rekram1-node (assign any acp issues to rekram1-node)

View File

@@ -1,8 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
"provider": {
"opencode": {
"options": {},

View File

@@ -1,42 +0,0 @@
---
name: bun-file-io
description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
---
## Use this when
- Editing file I/O or scans in `packages/opencode`
- Handling directory operations or external tools
## Bun file APIs (from Bun docs)
- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read.
- Metadata: `file.size`, `file.type`, `file.name`.
- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files.
- `Bun.file(...).delete()` deletes a file.
- `file.writer()` returns a FileSink for incremental writes.
- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans.
- Use `Bun.which` to find a binary, then `Bun.spawn` to run it.
- `Bun.readableStreamToText/Bytes/JSON` for stream output.
## When to use node:fs
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations).
## Repo patterns
- Prefer Bun APIs over Node `fs` for file access.
- Check `Bun.file(...).exists()` before reading.
- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`.
- Use `Bun.Glob` + `Array.fromAsync` for scans.
- Decode tool stderr with `Bun.readableStreamToText`.
- For large writes, use `Bun.write(Bun.file(path), text)`.
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
Use Filesystem.exists(...) instead if path can be file or directory
## Quick checklist
- Use Bun APIs first.
- Use `path.join`/`path.resolve` for paths.
- Prefer promise `.catch(...)` over `try/catch` when possible.

View File

@@ -1,8 +1,22 @@
/// <reference path="../env.d.ts" />
// import { Octokit } from "@octokit/rest"
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}
function getIssueNumber(): number {
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
@@ -29,60 +43,69 @@ export default tool({
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"]))
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
.default([]),
},
async execute(args) {
const issue = getIssueNumber()
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
const owner = "anomalyco"
const repo = "opencode"
const results: string[] = []
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
const web = labels.includes("web")
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
const nix = /\bnix(os)?\b/.test(text)
if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) {
throw new Error("Only desktop issues should be assigned to adamdotdevin")
if (labels.includes("nix") && !nix) {
labels = labels.filter((x) => x !== "nix")
results.push("Dropped label: nix (issue does not mention nix)")
}
if (args.assignee === "fwang" && !args.labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang")
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
}
if (args.assignee === "kommander" && !args.labels.includes("opentui")) {
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
}
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
}
if (assignee === "Hona" && !labels.includes("windows")) {
throw new Error("Only windows issues should be assigned to Hona")
}
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
throw new Error("Only docs issues should be assigned to R44VC0RP")
}
if (assignee === "kommander" && !labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander")
}
// await octokit.rest.issues.addAssignees({
// owner,
// repo,
// issue_number: issue,
// assignees: [args.assignee],
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST",
body: JSON.stringify({ assignees: [args.assignee] }),
body: JSON.stringify({ assignees: [assignee] }),
})
results.push(`Assigned @${args.assignee} to issue #${issue}`)
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
results.push(`Assigned @${assignee} to issue #${issue}`)
if (labels.length > 0) {
// await octokit.rest.issues.addLabels({
// owner,
// repo,
// issue_number: issue,
// labels,
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST",
body: JSON.stringify({ labels }),
})
results.push(`Added labels: ${args.labels.join(", ")}`)
results.push(`Added labels: ${labels.join(", ")}`)
}
return results.join("\n")

View File

@@ -1,88 +1,6 @@
Use this tool to assign and/or label a GitHub issue.
You can assign the following users:
- thdxr
- adamdotdevin
- fwang
- jayair
- kommander
- rekram1-node
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
You can use the following labels:
- nix
- opentui
- perf
- web
- zen
- docs
Always try to assign an issue, if in doubt, assign rekram1-node to it.
## Breakdown of responsibilities:
### thdxr
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
This relates to OpenCode server primarily but has overlap with just about anything
### adamdotdevin
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
### fwang
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
### jayair
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
### kommander
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
- random characters on screen
- keybinds not working on different terminals
- general terminal stuff
Then assign the issue to Him.
### rekram1-node
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
If no one else makes sense to assign, assign rekram1-node to it.
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
## Breakdown of Labels:
### nix
Any issue that mentions nix, or nixos should have a nix label
### opentui
Anything relating to the TUI itself should have an opentui label
### perf
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
### desktop
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
### zen
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
### docs
Anything related to the documentation should have a docs label
### windows
Use for any issue that involves the windows OS
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.

9
.zed/settings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "bunx",
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
}
}
}

View File

@@ -24,6 +24,11 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
## Adding New Providers
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
https://github.com/anomalyco/models.dev
## Developing OpenCode
- Requirements: Bun 1.3+

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -32,7 +32,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -32,7 +32,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

139
README.uk.md Normal file
View File

@@ -0,0 +1,139 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">AI-агент для програмування з відкритим кодом.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a> |
<a href="README.zht.md">繁體中文</a> |
<a href="README.ko.md">한국어</a> |
<a href="README.de.md">Deutsch</a> |
<a href="README.es.md">Español</a> |
<a href="README.fr.md">Français</a> |
<a href="README.it.md">Italiano</a> |
<a href="README.da.md">Dansk</a> |
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Встановлення
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Менеджери пакетів
npm i -g opencode-ai@latest # або bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
brew install opencode # macOS і Linux (офіційна формула Homebrew, оновлюється рідше)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Будь-яка ОС
nix run nixpkgs#opencode # або github:anomalyco/opencode для найновішої dev-гілки
```
> [!TIP]
> Перед встановленням видаліть версії старші за 0.1.x.
### Десктопний застосунок (BETA)
OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download).
| Платформа | Завантаження |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` або AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Каталог встановлення
Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення:
1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення
2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory
3. `$HOME/bin` - Стандартний каталог користувацьких бінарників (якщо існує або його можна створити)
4. `$HOME/.opencode/bin` - Резервний варіант за замовчуванням
```bash
# Приклади
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Агенти
OpenCode містить два вбудовані агенти, між якими можна перемикатися клавішею `Tab`.
- **build** - Агент за замовчуванням із повним доступом для завдань розробки
- **plan** - Агент лише для читання для аналізу та дослідження коду
- За замовчуванням забороняє редагування файлів
- Запитує дозвіл перед запуском bash-команд
- Ідеально підходить для дослідження незнайомих кодових баз або планування змін
Також доступний допоміжний агент **general** для складного пошуку та багатокрокових завдань.
Він використовується всередині системи й може бути викликаний у повідомленнях через `@general`.
Дізнайтеся більше про [agents](https://opencode.ai/docs/agents).
### Документація
Щоб дізнатися більше про налаштування OpenCode, [**перейдіть до нашої документації**](https://opencode.ai/docs).
### Внесок
Якщо ви хочете зробити внесок в OpenCode, будь ласка, прочитайте нашу [документацію для контриб'юторів](./CONTRIBUTING.md) перед надсиланням pull request.
### Проєкти на базі OpenCode
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
### FAQ
#### Чим це відрізняється від Claude Code?
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
- 100% open source
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
- Підтримка LSP з коробки
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
---
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -31,7 +31,8 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -1,5 +1,11 @@
# Security
## IMPORTANT
We do not accept AI generated security reports. We receive a large number of
these and we absolutely do not have the resources to review them all. If you
submit one that will be an automatic ban from the project.
## Threat Model
### Overview

481
bun.lock

File diff suppressed because it is too large Load Diff

1
github/sst-env.d.ts vendored
View File

@@ -2,6 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../sst-env.d.ts" />

View File

@@ -145,6 +145,16 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS18"),
new sst.Secret("ZEN_MODELS19"),
new sst.Secret("ZEN_MODELS20"),
new sst.Secret("ZEN_MODELS21"),
new sst.Secret("ZEN_MODELS22"),
new sst.Secret("ZEN_MODELS23"),
new sst.Secret("ZEN_MODELS24"),
new sst.Secret("ZEN_MODELS25"),
new sst.Secret("ZEN_MODELS26"),
new sst.Secret("ZEN_MODELS27"),
new sst.Secret("ZEN_MODELS28"),
new sst.Secret("ZEN_MODELS29"),
new sst.Secret("ZEN_MODELS30"),
]
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
@@ -204,9 +214,9 @@ new sst.cloudflare.x.SolidStart("Console", {
},
transform: {
server: {
placement: { region: "aws:us-east-1" },
transform: {
worker: {
placement: { mode: "smart" },
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-5pgd2xuvIIkTbIOGIdK5MIXo6O9qRpvk1RKQZ1e1R+8=",
"aarch64-linux": "sha256-FZiHwihM4b82ipQ9XfW08X+sd5CvZhx/+pU/8X1zsns=",
"aarch64-darwin": "sha256-iZv0w1NthV53pY5uvuf3JlI14GeKmCu7WHwGSRdEQeM=",
"x86_64-darwin": "sha256-c3Zm3P1goFPgg3vNAZPMFOhHX/gyTmsCN/PKbGO/v0E="
"x86_64-linux": "sha256-fjrvCgQ2PHYxzw8NsiEHOcor46qN95/cfilFHFqCp/k=",
"aarch64-linux": "sha256-xWp4LLJrbrCPFL1F6SSbProq/t/az4CqhTcymPvjOBQ=",
"aarch64-darwin": "sha256-Wbfyy/bruFHKUWsyJ2aiPXAzLkk5MNBfN6QdGPQwZS0=",
"x86_64-darwin": "sha256-wDnMbiaBCRj5STkaLoVCZTdXVde+/YKfwWzwJZ1AJXQ="
}
}

View File

@@ -69,10 +69,12 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.17.23",
"sst": "3.18.10",
"turbo": "2.5.6"
},
"dependencies": {

View File

@@ -332,6 +332,163 @@ export async function withSession<T>(
}
}
const seedSystem = [
"You are seeding deterministic e2e UI state.",
"Follow the user's instruction exactly.",
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
"Do not call any extra tools.",
].join(" ")
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
const timeout = input.timeout ?? 30_000
const end = Date.now() + timeout
while (Date.now() < end) {
const value = await input.probe()
if (value !== undefined) return value
await new Promise((resolve) => setTimeout(resolve, 250))
}
}
const seed = async <T>(input: {
sessionID: string
prompt: string
sdk: ReturnType<typeof createSdk>
probe: () => Promise<T | undefined>
timeout?: number
attempts?: number
}) => {
for (let i = 0; i < (input.attempts ?? 2); i++) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
agent: "build",
system: seedSystem,
parts: [{ type: "text", text: input.prompt }],
})
const value = await wait({ probe: input.probe, timeout: input.timeout })
if (value !== undefined) return value
}
}
export async function seedSessionQuestion(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
questions: Array<{
header: string
question: string
options: Array<{ label: string; description: string }>
multiple?: boolean
custom?: boolean
}>
},
) {
const first = input.questions[0]
if (!first) throw new Error("Question seed requires at least one question")
const text = [
"Your only valid response is one question tool call.",
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
"Do not output plain text.",
"After calling the tool, wait for the user response.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.question.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
},
})
if (!result) throw new Error("Timed out seeding question request")
return { id: result.id }
}
export async function seedSessionPermission(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
permission: string
patterns: string[]
description?: string
},
) {
const text = [
"Your only valid response is one bash tool call.",
`Use this JSON input: ${JSON.stringify({
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
workdir: "/",
description: input.description ?? `seed ${input.permission} permission request`,
})}`,
"Do not output plain text.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.permission.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID)
},
})
if (!result) throw new Error("Timed out seeding permission request")
return { id: result.id }
}
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
todos: Array<{ content: string; status: string; priority: string }>
},
) {
const text = [
"Your only valid response is one todowrite tool call.",
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
"Do not output plain text.",
].join("\n")
const target = JSON.stringify(input.todos)
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
if (JSON.stringify(todos) !== target) return
return true
},
})
if (!result) throw new Error("Timed out seeding todos")
return true
}
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []),
sdk.permission.list().then((x) => x.data ?? []),
])
await Promise.all([
...questions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
...permissions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
])
return true
}
export async function openStatusPopover(page: Page) {
await defocus(page)

View File

@@ -0,0 +1,15 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await page.locator("main").click({ position: { x: 5, y: 5 } })
await expect(prompt).not.toBeFocused()
await page.keyboard.press("Control+L")
await expect(prompt).toBeFocused()
})

View File

@@ -0,0 +1,31 @@
import { test, expect } from "../fixtures"
import { modKey } from "../utils"
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
const value = await el.getAttribute("aria-expanded")
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
return value === "true"
}
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
await gotoSession()
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
await expect(treeToggle).toBeVisible()
if (await expanded(treeToggle)) await treeToggle.click()
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
await expect(reviewToggle).toBeVisible()
if (await expanded(reviewToggle)) await reviewToggle.click()
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toBeVisible()
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
})

View File

@@ -0,0 +1,32 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await dialog.getByRole("textbox").first().fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" }).first()
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press(`${modKey}+W`)
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
})

View File

@@ -28,7 +28,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
const model = key.split(":").slice(1).join(":")
await input.fill(model)
@@ -37,6 +36,13 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
await expect(dialog).toHaveCount(0)
const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]")
await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialogAgain = page.getByRole("dialog")
await expect(dialogAgain).toBeVisible()
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
})

View File

@@ -1,7 +1,19 @@
import { base64Decode } from "@opencode-ai/util/encode"
import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject } from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
import {
defocus,
createTestProject,
cleanupTestProject,
openSidebar,
setWorkspacesEnabled,
sessionIDFromUrl,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -33,3 +45,94 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
await cleanupTestProject(other)
}
})
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
const stamp = Date.now()
let rootDir: string | undefined
let workspaceDir: string | undefined
let sessionID: string | undefined
try {
await withProject(
async ({ directory, slug }) => {
rootDir = directory
await defocus(page)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const next = slugFromUrl(page.url())
if (!next) return ""
if (next === slug) return ""
return next
},
{ timeout: 45_000 },
)
.not.toBe("")
const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(workspace).toBeVisible()
await workspace.hover()
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
await expect(newSession).toBeVisible()
await newSession.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(`project switch remembers workspace ${stamp}`)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
sessionID = created
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click()
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally {
if (sessionID) {
const id = sessionID
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
await Promise.all(
dirs.map((directory) =>
createSdk(directory)
.session.delete({ sessionID: id })
.catch(() => undefined),
),
)
}
if (workspaceDir) {
await cleanupTestProject(workspaceDir)
}
await cleanupTestProject(other)
}
})

View File

@@ -0,0 +1,22 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
const dt = await page.evaluateHandle((text) => {
const dt = new DataTransfer()
dt.setData("text/plain", text)
return dt
}, `file:${path}`)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
await expect(pill).toBeVisible()
await expect(pill).toHaveAttribute("data-path", path)
})

View File

@@ -0,0 +1,30 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
const dt = await page.evaluateHandle((b64) => {
const dt = new DataTransfer()
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
const file = new File([bytes], "drop.png", { type: "image/png" })
dt.items.add(file)
return dt
}, png)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const img = page.locator('img[alt="drop.png"]').first()
await expect(img).toBeVisible()
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
await expect(remove).toBeVisible()
await img.hover()
await remove.click()
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
})

View File

@@ -0,0 +1,18 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
await gotoSession()
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
})

View File

@@ -0,0 +1,23 @@
import { test, expect } from "../fixtures"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector)
await expect(terminal).not.toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()
})

View File

@@ -1,5 +1,15 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'

View File

@@ -0,0 +1,207 @@
import { test, expect } from "../fixtures"
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
questionDockSelector,
sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
const session = await sdk.session.create({ title }).then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
return fn(session)
}
test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
try {
return await fn()
} finally {
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
})
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["README.md"],
description: "Need permission for command",
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow once/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["REJECT.md"],
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["README.md"],
description: "Need permission for command",
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow always/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
})
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
})
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
})
})

View File

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

View File

@@ -1,35 +1,36 @@
import "@/index.css"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync"
import { PermissionProvider } from "@/context/permission"
import { LayoutProvider } from "@/context/layout"
import { MetaProvider } from "@solidjs/meta"
import { Navigate, Route, Router } from "@solidjs/router"
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" />
@@ -57,7 +58,11 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
}
}
}
@@ -107,30 +112,6 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
)
}
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
@@ -157,27 +138,19 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.url} keyed>
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
const platform = usePlatform()
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
const defaultServerUrl = resolveDefaultServerUrl({
defaultUrl: props.defaultUrl,
storedDefaultServerUrl,
hostname: location.hostname,
origin: window.location.origin,
isDev: import.meta.env.DEV,
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
})
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) {
return (
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>

View File

@@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: {
}}
modal={false}
placement="top-start"
gutter={8}
gutter={4}
>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}

View File

@@ -1,19 +1,18 @@
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { useGlobalSDK } from "@/context/global-sdk"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps {
@@ -89,7 +88,7 @@ function useServerPreview(fetcher: typeof fetch) {
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth(normalized, fetcher)
const result = await checkServerHealth({ url: normalized }, fetcher)
setStatus(result.healthy)
}
@@ -171,14 +170,13 @@ export function DialogSelectServer() {
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>,
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: {
url: "",
adding: false,
@@ -214,24 +212,25 @@ export function DialogSelectServer() {
})
}
const replaceServer = (original: string, next: string) => {
const active = server.url
const nextActive = active === original ? next : active
const replaceServer = (original: ServerConnection.Http, next: string) => {
const active = server.key
const newConn = server.add(next)
if (!newConn) return
server.add(next)
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(original)
server.remove(ServerConnection.key(original))
}
const items = createMemo(() => {
const current = server.url
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const sortedItems = createMemo(() => {
const list = items()
@@ -246,17 +245,17 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b])
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<string, ServerHealth> = {}
const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all(
items().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
items().map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}),
)
setStore("status", reconcile(results))
@@ -269,15 +268,15 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval))
})
async function select(value: string, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close()
if (persist) {
server.add(value)
server.add(conn.http.url)
navigate("/")
return
}
server.setActive(value)
server.setActive(ServerConnection.key(conn))
navigate("/")
}
@@ -311,7 +310,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth(normalized, fetcher)
const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
@@ -320,25 +319,25 @@ export function DialogSelectServer() {
}
resetAdd()
await select(normalized, true)
await select({ type: "http", http: { url: normalized } }, true)
}
async function handleEdit(original: string, value: string) {
if (store.editServer.busy) return
async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetEdit()
return
}
if (normalized === original) {
if (normalized === original.http.url) {
resetEdit()
return
}
setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth(normalized, fetcher)
const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
@@ -366,7 +365,7 @@ export function DialogSelectServer() {
handleAdd(store.addServer.url)
}
const handleEditKey = (event: KeyboardEvent, original: string) => {
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
@@ -378,7 +377,7 @@ export function DialogSelectServer() {
handleEdit(original, store.editServer.value)
}
async function handleRemove(url: string) {
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null)
@@ -390,11 +389,14 @@ export function DialogSelectServer() {
<div class="flex flex-col gap-2">
<div ref={(el) => (listRoot = el)}>
<List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) select(x)
}}
@@ -425,10 +427,11 @@ export function DialogSelectServer() {
}
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i}
when={store.editServer.id !== i.http.url}
fallback={
<EditRow
value={store.editServer.value}
@@ -443,12 +446,12 @@ export function DialogSelectServer() {
}
>
<ServerRow
url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
conn={i}
status={store.status[key]}
dimmed={store.status[key]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i}>
<Show when={defaultUrl() === i.http.url}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
@@ -456,59 +459,63 @@ export function DialogSelectServer() {
}
/>
</Show>
<Show when={store.editServer.id !== i}>
<Show when={store.editServer.id !== i.http.url}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<Show when={ServerConnection.key(current()) === key}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i}>
<DropdownMenu.Item onSelect={() => setDefault(i)}>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i.http.url,
value: i.http.url,
error: "",
status: store.status[ServerConnection.key(i)]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</Show>
</div>

View File

@@ -71,13 +71,13 @@ const kindLabel = (kind: Kind) => {
const kindTextColor = (kind: Kind) => {
if (kind === "add") return "color: var(--icon-diff-add-base)"
if (kind === "del") return "color: var(--icon-diff-delete-base)"
return "color: var(--icon-warning-active)"
return "color: var(--icon-diff-modified-base)"
}
const kindDotColor = (kind: Kind) => {
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
return "background-color: var(--icon-warning-active)"
return "background-color: var(--icon-diff-modified-base)"
}
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
@@ -447,12 +447,13 @@ export default function FileTree(props: {
})
return (
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1
const kind = () => visibleKind(node, kinds(), marks())
const active = () => !!kind() && !node.ignored
return (
<Switch>
@@ -530,7 +531,37 @@ export default function FileTree(props: {
onClick={() => props.onFileClick?.(node)}
>
<div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" />
<Switch>
<Match when={node.ignored}>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono"
style="color: var(--icon-weak-base)"
mono
/>
</Match>
<Match when={active()}>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono"
style={kindTextColor(kind()!)}
mono
/>
</Match>
<Match when={!node.ignored}>
<span class="filetree-iconpair size-4">
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
/>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
mono
/>
</span>
</Match>
</Switch>
</FileTreeNode>
</FileTreeNodeTooltip>
</Match>

View File

@@ -1,5 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
@@ -20,19 +20,20 @@ import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { RadioGroup } from "@opencode-ai/ui/radio-group"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@@ -94,7 +95,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
const layout = useLayout()
const comments = useComments()
const params = useParams()
@@ -105,11 +105,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const language = useLanguage()
const platform = usePlatform()
let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
const mirror = { input: false }
const inset = 44
const scrollCursorIntoView = () => {
const container = scrollRef
@@ -119,7 +120,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return
const rect = range.getBoundingClientRect()
const cursor = getCursorPosition(editorRef)
const length = promptLength(prompt.current().filter((part) => part.type !== "image"))
if (cursor >= length) {
container.scrollTop = container.scrollHeight
return
}
const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect()
if (!rect.height) return
const containerRect = container.getBoundingClientRect()
@@ -132,8 +140,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (bottom > container.scrollTop + container.clientHeight - padding) {
container.scrollTop = bottom - container.clientHeight + padding
if (bottom > container.scrollTop + container.clientHeight - inset) {
container.scrollTop = bottom - container.clientHeight + inset
}
}
@@ -223,16 +231,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mode: "normal",
applyingHistory: false,
})
const placeholder = createMemo(() =>
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: language.t(EXAMPLES[store.placeholder]),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const MAX_HISTORY = 100
const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const contextItems = createMemo(() => {
const items = prompt.context.items()
if (store.mode !== "shell") return items
return items.filter((item) => !item.comment?.trim())
})
const hasUserPrompt = createMemo(() => {
const sessionID = params.id
if (!sessionID) return false
const messages = sync.data.message[sessionID]
if (!messages) return false
return messages.some((m) => m.role === "user")
})
const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{
@@ -250,6 +268,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
const suggest = createMemo(() => !hasUserPrompt())
const placeholder = createMemo(() =>
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
suggest: suggest(),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true)
@@ -280,6 +310,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const isFocused = createFocusSignal(() => editorRef)
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click()
const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode)
setStore("popover", null)
requestAnimationFrame(() => editorRef?.focus())
}
const shellModeKey = "mod+shift+x"
const normalModeKey = "mod+shift+e"
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: store.mode !== "normal",
onSelect: pick,
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: shellModeKey,
disabled: store.mode === "shell",
onSelect: () => setMode("shell"),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: normalModeKey,
disabled: store.mode === "normal",
onSelect: () => setMode("normal"),
},
])
const closePopover = () => setStore("popover", null)
@@ -325,6 +394,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
createEffect(() => {
params.id
if (params.id) return
if (!suggest()) return
const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
}, 6500)
@@ -334,15 +404,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
createEffect(() => {
if (!isFocused()) closePopover()
})
// Safety: reset composing state on focus change to prevent stuck state
// This handles edge cases where compositionend event may not fire
createEffect(() => {
if (!isFocused()) setComposing(false)
})
const handleBlur = () => {
closePopover()
setComposing(false)
}
const agentList = createMemo(() =>
sync.data.agent
@@ -815,6 +880,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
event.preventDefault()
if (store.mode !== "normal") return
pick()
return
}
if (event.key === "Backspace") {
const selection = window.getSelection()
if (selection && selection.isCollapsed) {
@@ -842,13 +914,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
}
if (store.mode === "shell") {
const { collapsed, cursorPosition, textLength } = getCaretState()
if (event.key === "Escape") {
setStore("mode", "normal")
if (event.key === "Escape") {
if (store.popover) {
closePopover()
event.preventDefault()
event.stopPropagation()
return
}
if (store.mode === "shell") {
setStore("mode", "normal")
event.preventDefault()
event.stopPropagation()
return
}
if (working()) {
abort()
event.preventDefault()
event.stopPropagation()
return
}
if (escBlur()) {
editorRef.blur()
event.preventDefault()
event.stopPropagation()
return
}
}
if (store.mode === "shell") {
const { collapsed, cursorPosition, textLength } = getCaretState()
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
setStore("mode", "normal")
event.preventDefault()
@@ -911,31 +1009,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!collapsed) return
const cursorPosition = getCursorPosition(editorRef)
const textLength = promptLength(prompt.current())
const textContent = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
const direction = event.key === "ArrowUp" ? "up" : "down"
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0
const atStart = cursorPosition <= (isEmpty ? 1 : 0)
const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
if (direction === "up") {
if (!allowUp) return
if (navigateHistory("up")) {
event.preventDefault()
}
return
}
if (!allowDown) return
if (navigateHistory("down")) {
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return
if (navigateHistory(direction)) {
event.preventDefault()
}
return
@@ -945,17 +1025,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event)
}
if (event.key === "Escape") {
if (store.popover) {
closePopover()
} else if (working()) {
abort()
}
}
}
const variants = createMemo(() => ["default", ...local.model.variant.list()])
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
<PromptPopover
popover={store.popover}
setSlashPopoverRef={(el) => (slashPopoverRef = el)}
@@ -971,12 +1046,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
commandKeybind={command.keybind}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<form
<DockShellForm
onSubmit={handleSubmit}
classList={{
"group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
"focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
@@ -986,7 +1060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
/>
<PromptContextItems
items={prompt.context.items()}
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1006,162 +1080,60 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onRemove={removeImageAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty()}>
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{placeholder()}
</div>
</Show>
</div>
<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">
<Icon name="console" size="small" class="text-icon-primary" />
<span class="text-12-regular text-text-primary">{language.t("prompt.mode.shell")}</span>
<span class="text-12-regular text-text-weak">{language.t("prompt.mode.shell.exit")}</span>
</div>
</Match>
<Match when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
<Select
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
valueClass="truncate"
variant="ghost"
/>
</TooltipKeybind>
<Show
when={providers.paid().length > 0}
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 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>
<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", 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-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
onClick={() => local.model.variant.cycle()}
>
{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")}
>
<Button
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_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),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
</Show>
</Match>
</Switch>
<div
class="relative"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (
target.closest(
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
)
) {
return
}
editorRef?.focus()
}}
>
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty()}>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
>
{placeholder()}
</div>
</Show>
</div>
<div class="flex items-center gap-1 shrink-0">
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
@@ -1173,54 +1145,247 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1 mr-1">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1 transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
}}
>
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-8 p-0"
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
<div class="pointer-events-none absolute bottom-2 left-2">
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
type="button"
data-action="prompt-permissions"
variant="ghost"
class="size-6 px-1"
onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_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),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon name="photo" class="size-4.5" />
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</Tooltip>
</TooltipKeybind>
</div>
</div>
</Show>
</div>
</DockShellForm>
<Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<Show when={store.mode === "shell"}>
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
</Show>
<Show when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px" }}
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 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(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" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id as IconName}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(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>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
</Show>
</div>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
type="submit"
disabled={!prompt.dirty() && !working() && commentCount() === 0}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-4.5"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
<div class="shrink-0">
<RadioGroup
options={["shell", "normal"] as const}
current={store.mode}
value={(mode) => mode}
label={(mode) => (
<TooltipKeybind
placement="top"
gutter={4}
openDelay={2000}
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
class="size-full flex items-center justify-center"
>
<Icon
name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]"
classList={{
"text-icon-strong-base": store.mode === mode,
"text-icon-weak": store.mode !== mode,
}}
/>
</TooltipKeybind>
)}
onSelect={(mode) => mode && setMode(mode)}
fill
pad="none"
class="w-[68px]"
/>
</Tooltip>
</div>
</div>
</div>
</form>
</DockTray>
</Show>
</div>
)
}

View File

@@ -41,10 +41,9 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
selected,
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
"bg-background-stronger": !selected,
}}
onClick={() => props.openComment(item)}

View File

@@ -73,7 +73,7 @@ describe("prompt-input history", () => {
expect(original[1].selection?.startLine).toBe(1)
})
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
@@ -85,7 +85,16 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
})
})

View File

@@ -4,11 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
if (!text.includes("\n")) return true
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length))
if (direction === "up") return !text.slice(0, position).includes("\n")
return !text.slice(position).includes("\n")
const atStart = position === 0
const atEnd = position === text.length
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0
return position === text.length
}
export function clonePromptParts(prompt: Prompt): Prompt {

View File

@@ -9,27 +9,40 @@ describe("promptPlaceholder", () => {
mode: "shell",
commentCount: 0,
example: "example",
suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.shell")
})
test("returns summarize placeholders for comment context", () => {
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComment",
)
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComments",
)
})
test("returns default placeholder with example", () => {
test("returns default placeholder with example when suggestions enabled", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.normal:translated-example")
})
test("returns simple placeholder when suggestions disabled", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
suggest: false,
t,
})
expect(value).toBe("prompt.placeholder.simple")
})
})

View File

@@ -2,6 +2,7 @@ type PromptPlaceholderInput = {
mode: "normal" | "shell"
commentCount: number
example: string
suggest: boolean
t: (key: string, params?: Record<string, string>) => string
}
@@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example })
}

View File

@@ -40,9 +40,9 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
ref={(el) => {
if (props.popover === "slash") props.setSlashPopoverRef(el)
}}
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
onMouseDown={(e) => e.preventDefault()}
>
<Switch>

View File

@@ -12,24 +12,27 @@ let selected = "/repo/worktree-a"
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const clientFor = (directory: string) => ({
session: {
create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
session: {
create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
})
}
}
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
@@ -88,11 +91,17 @@ beforeAll(async () => {
}))
mock.module("@/context/sdk", () => ({
useSDK: () => ({
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",
}),
useSDK: () => {
const sdk = {
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
},
}
return sdk
},
}))
mock.module("@/context/sync", () => ({

View File

@@ -1,21 +1,20 @@
import { Accessor } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLocal } from "@/context/local"
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
type PendingPrompt = {
abort: AbortController
@@ -56,7 +55,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal()
const prompt = usePrompt()
const layout = useLayout()
@@ -75,6 +73,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
globalSync.todo.set(sessionID, [])
const [, setStore] = globalSync.child(sdk.directory)
setStore("todo", sessionID, [])
const queued = pending.get(sessionID)
if (queued) {
queued.abort.abort()
@@ -171,9 +174,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
client = createOpencodeClient({
baseUrl: sdk.url,
fetch: platform.fetch,
client = sdk.createClient({
directory: sessionDirectory,
throwOnError: true,
})
@@ -368,7 +369,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs)
})

View File

@@ -1,287 +0,0 @@
import { For, Show, createMemo, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
const sdk = useSDK()
const language = useLanguage()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
custom: [] as string[],
editing: false,
sending: false,
})
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "")
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
const value = input()
if (!value) return false
return store.answers[store.tab]?.includes(value) ?? false
})
const fail = (err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
}
const reply = async (answers: QuestionAnswer[]) => {
if (store.sending) return
setStore("sending", true)
try {
await sdk.client.question.reply({ requestID: props.request.id, answers })
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
}
const reject = async () => {
if (store.sending) return
setStore("sending", true)
try {
await sdk.client.question.reject({ requestID: props.request.id })
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
}
const submit = () => {
void reply(questions().map((_, i) => store.answers[i] ?? []))
}
const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer])
if (custom) {
setStore("custom", store.tab, answer)
}
if (single()) {
void reply([[answer]])
return
}
setStore("tab", store.tab + 1)
}
const toggle = (answer: string) => {
setStore("answers", store.tab, (current = []) => {
if (current.includes(answer)) return current.filter((item) => item !== answer)
return [...current, answer]
})
}
const selectTab = (index: number) => {
setStore("tab", index)
setStore("editing", false)
}
const selectOption = (optIndex: number) => {
if (store.sending) return
if (optIndex === options().length) {
setStore("editing", true)
return
}
const opt = options()[optIndex]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
const handleCustomSubmit = (e: Event) => {
e.preventDefault()
if (store.sending) return
const value = input().trim()
if (!value) {
setStore("editing", false)
return
}
if (multi()) {
setStore("answers", store.tab, (current = []) => {
if (current.includes(value)) return current
return [...current, value]
})
setStore("editing", false)
return
}
pick(value, true)
setStore("editing", false)
}
return (
<div data-component="question-prompt">
<Show when={!single()}>
<div data-slot="question-tabs">
<For each={questions()}>
{(q, index) => {
const active = () => index() === store.tab
const answered = () => (store.answers[index()]?.length ?? 0) > 0
return (
<button
data-slot="question-tab"
data-active={active()}
data-answered={answered()}
disabled={store.sending}
onClick={() => selectTab(index())}
>
{q.header}
</button>
)
}}
</For>
<button
data-slot="question-tab"
data-active={confirm()}
disabled={store.sending}
onClick={() => selectTab(questions().length)}
>
{language.t("ui.common.confirm")}
</button>
</div>
</Show>
<Show when={!confirm()}>
<div data-slot="question-content">
<div data-slot="question-text">
{question()?.question}
{multi() ? " " + language.t("ui.question.multiHint") : ""}
</div>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<Show when={picked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
)
}}
</For>
<button
data-slot="question-option"
data-picked={customPicked()}
disabled={store.sending}
onClick={() => selectOption(options().length)}
>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<Show when={!store.editing && input()}>
<span data-slot="option-description">{input()}</span>
</Show>
<Show when={customPicked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
<Show when={store.editing}>
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
type="text"
data-slot="custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
disabled={store.sending}
onInput={(e) => {
setStore("custom", store.tab, e.currentTarget.value)
}}
/>
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
</Button>
<Button
type="button"
variant="ghost"
size="small"
disabled={store.sending}
onClick={() => setStore("editing", false)}
>
{language.t("ui.common.cancel")}
</Button>
</form>
</Show>
</div>
</div>
</Show>
<Show when={confirm()}>
<div data-slot="question-review">
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : language.t("ui.question.review.notAnswered")}
</span>
</div>
)
}}
</For>
</div>
</Show>
<div data-slot="question-actions">
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
{language.t("ui.common.dismiss")}
</Button>
<Show when={!single()}>
<Show when={confirm()}>
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
{language.t("ui.common.submit")}
</Button>
</Show>
<Show when={!confirm() && multi()}>
<Button
variant="secondary"
size="small"
onClick={() => selectTab(store.tab + 1)}
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
>
{language.t("ui.common.next")}
</Button>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -1,10 +1,19 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { serverDisplayName } from "@/context/server"
import {
createEffect,
createMemo,
createSignal,
type JSXElement,
onCleanup,
onMount,
type ParentProps,
Show,
} from "solid-js"
import { type ServerConnection, serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
url: string
conn: ServerConnection.Any
status?: ServerHealth
class?: string
nameClass?: string
@@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.url))
const name = createMemo(() => serverDisplayName(props.conn))
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => {
name()
props.url
props.conn.http.url
props.status?.version
queueMicrotask(check)
})

View File

@@ -1,5 +1,5 @@
import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
@@ -11,6 +11,7 @@ import { getSessionContextMetrics } from "@/components/session/session-context-m
interface SessionContextUsageProps {
variant?: "button" | "indicator"
placement?: TooltipProps["placement"]
}
function openSessionContext(args: {
@@ -52,6 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
if (tabs().active() === "context") {
tabs().close("context")
return
}
openSessionContext({
view: view(),
layout,
@@ -90,7 +96,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return (
<Show when={params.id}>
<Tooltip value={tooltipValue()} placement="top">
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={true}>

View File

@@ -5,6 +5,7 @@ import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -16,13 +17,6 @@ import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
@@ -91,11 +85,45 @@ function RawMessage(props: {
)
}
export function SessionContextTab(props: SessionContextTabProps) {
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const params = useParams()
const sync = useSync()
const layout = useLayout()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(
() => {
const id = params.id
if (!id) return emptyMessages
return (sync.data.message[id] ?? []) as Message[]
},
emptyMessages,
{ equals: same },
)
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
@@ -104,7 +132,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
@@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const counts = createMemo(() => {
const all = props.messages()
const all = messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return {
@@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const systemPrompt = createMemo(() => {
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()
@@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) {
const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: props.messages(),
messages: messages(),
parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
@@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}
const stats = [
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
@@ -186,7 +214,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
@@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const el = scroll
if (!el) return
const s = props.view()?.scroll("context")
const s = view().scroll("context")
if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
@@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
pending = undefined
if (!next) return
props.view().setScroll("context", next)
view().setScroll("context", next)
})
}
createEffect(
on(
() => props.messages().length,
() => messages().length,
() => {
requestAnimationFrame(restoreScroll)
},
@@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple>
<For each={props.messages()}>
<For each={messages()}>
{(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)}

View File

@@ -257,27 +257,12 @@ export function SessionHeader() {
] as const
})
const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true
const list = apps()
return list.every((app) => exists[app.id] !== undefined)
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
createEffect(() => {
if (platform.platform !== "desktop") return
if (!checksReady()) return
const value = prefs.app
if (options().some((o) => o.id === value)) return
setPrefs("app", options()[0]?.id ?? "finder")
})
const openDir = (app: OpenApp) => {
const directory = projectDirectory()
if (!directory) return
@@ -311,25 +296,25 @@ export function SessionHeader() {
platform,
})
const leftMount = createMemo(
() => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
)
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return (
<>
<Show when={leftMount()}>
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<button
<Button
type="button"
class="hidden md:flex w-[320px] max-w-full min-w-0 h-[24px] px-2 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-base bg-surface-panel transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-visible">
<Icon name="magnifying-glass" size="normal" class="icon-base shrink-0" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-4.5 flex items-center">
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", { project: name() })}
</span>
</div>
@@ -339,24 +324,24 @@ export function SessionHeader() {
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
)}
</Show>
</button>
</Button>
</Portal>
)}
</Show>
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<StatusPopover />
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-2 border-none shadow-none"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
@@ -369,10 +354,10 @@ export function SessionHeader() {
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={() => openDir(current().id)}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
@@ -381,9 +366,9 @@ export function SessionHeader() {
</div>
<span class="text-12-regular text-text-strong">Open</span>
</Button>
<div class="self-stretch w-px bg-border-base/70" />
<div class="self-stretch w-px bg-border-weak-base" />
<DropdownMenu
gutter={6}
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
@@ -392,7 +377,7 @@ export function SessionHeader() {
as={IconButton}
icon="chevron-down"
variant="ghost"
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-hover"
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
@@ -400,7 +385,7 @@ export function SessionHeader() {
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
value={prefs.app}
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
setPrefs("app", value as OpenApp)
@@ -458,7 +443,7 @@ export function SessionHeader() {
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
gutter={6}
gutter={4}
placement="bottom-end"
shift={-64}
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
@@ -466,11 +451,11 @@ export function SessionHeader() {
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
classList: { "rounded-r-none": share.shareUrl() !== undefined },
style: { scale: 1 },
}}
trigger={language.t("session.share.action.share")}
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
>
<div class="flex flex-col gap-2">
<Show
@@ -539,7 +524,7 @@ export function SessionHeader() {
<IconButton
icon={share.state.copied ? "check" : "link"}
variant="ghost"
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={share.state.unshare}
aria-label={
@@ -552,94 +537,97 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<div class="flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle size-6 p-0"
onClick={() => view().terminal.toggle()}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
<div class="flex items-center gap-1">
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<div class="hidden lg:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().terminal.toggle()}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<div class="hidden lg:block shrink-0">
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="group/file-tree-toggle size-6 p-0"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name="bullet-list"
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</div>
</Portal>

View File

@@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
const ROOT_CLASS =
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
interface NewSessionViewProps {
worktree: string

View File

@@ -128,6 +128,7 @@ export const SettingsGeneral: Component = () => {
{ value: "roboto-mono", label: "font.option.robotoMono" },
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
{ value: "geist-mono", label: "font.option.geistMono" },
] as const
const fontOptionsList = [...fontOptions]
@@ -430,7 +431,7 @@ export const SettingsGeneral: Component = () => {
<SoundsSection />
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
@@ -456,7 +457,7 @@ export const SettingsGeneral: Component = () => {
</div>
)
}}
</Show>
</Show>*/}
<UpdatesSection />

View File

@@ -1,21 +1,21 @@
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Popover } from "@opencode-ai/ui/popover"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
}
const listServersByHealth = (
list: string[],
active: string | undefined,
status: Record<string, ServerHealth | undefined>,
list: ServerConnection.Any[],
active: ServerConnection.Key | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
@@ -45,16 +45,16 @@ const listServersByHealth = (
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(status[a]) - rank(status[b])
if (ServerConnection.key(a) === active) return -1
if (ServerConnection.key(b) === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
const list = servers()
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}),
)
if (dead) return
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
return status
}
const useDefaultServerUrl = (
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [url, setUrl] = createSignal<string | undefined>()
@@ -117,7 +117,14 @@ const useDefaultServerUrl = (
})
})
return { url, refresh: () => setTick((value) => value + 1) }
return {
key: () => {
const u = url()
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setTick((value) => value + 1),
}
}
const useMcpToggle = (input: {
@@ -163,16 +170,16 @@ export function StatusPopover() {
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.url
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -196,24 +203,26 @@ export function StatusPopover() {
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
style: { scale: 1 },
}}
trigger={
<div class="flex items-center gap-1.5">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<div class="flex items-center gap-0.5">
<div class="size-4 flex items-center justify-center">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
</div>
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
</div>
}
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={6}
gutter={4}
placement="bottom-end"
shift={-136}
>
@@ -249,8 +258,9 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(url) => {
const isBlocked = () => health[url]?.healthy === false
{(s) => {
const key = ServerConnection.key(s)
const isBlocked = () => health[key]?.healthy === false
return (
<button
type="button"
@@ -262,19 +272,19 @@ export function StatusPopover() {
aria-disabled={isBlocked()}
onClick={() => {
if (isBlocked()) return
server.setActive(url)
server.setActive(key)
navigate("/")
}}
>
<ServerRow
url={url}
status={health[url]}
conn={s}
status={health[key]}
dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={url === defaultServer.url()}>
<Show when={key === defaultServer.key()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
@@ -282,7 +292,7 @@ export function StatusPopover() {
}
>
<div class="flex-1" />
<Show when={url === server.url}>
<Show when={server.current && key === ServerConnection.key(server.current)}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>

View File

@@ -1,19 +1,54 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings"
import { parseKeybind, matchKeybind } from "@/context/command"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
const FRAME_META = 0
const FRAME_OUTPUT = 1
const FRAME_INPUT = 2
const encoder = new TextEncoder()
const connection = () => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const frameInput = (id: string, data: string) => {
const channel = encoder.encode(id)
const body = encoder.encode(data)
const out = new Uint8Array(2 + channel.length + body.length)
out[0] = FRAME_INPUT
out[1] = channel.length
out.set(channel, 2)
out.set(body, 2 + channel.length)
return out
}
const frameOutput = (bytes: Uint8Array, decoder: TextDecoder) => {
if (bytes[0] !== FRAME_OUTPUT) return
const size = bytes[1]
if (!Number.isSafeInteger(size) || size < 0) return
if (bytes.length < 2 + size) return
return {
connection: decoder.decode(bytes.subarray(2, 2 + size)),
data: decoder.decode(bytes.subarray(2 + size)),
}
}
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
onSubmit?: () => void
@@ -106,8 +141,14 @@ const useTerminalUiBindings = (input: {
input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
input.container.addEventListener("click", input.handleLinkClick, {
capture: true,
})
input.cleanups.push(() =>
input.container.removeEventListener("click", input.handleLinkClick, {
capture: true,
}),
)
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
@@ -148,6 +189,7 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings()
const theme = useTheme()
const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
let ws: WebSocket | undefined
@@ -312,8 +354,6 @@ export const Terminal = (props: TerminalProps) => {
const mod = loaded.mod
const g = loaded.ghostty
const once = { value: false }
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
@@ -346,7 +386,7 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
output = terminalWriter((data) => t.write(data))
output = terminalWriter((data, done) => t.write(data, done))
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
@@ -372,7 +412,13 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer
t.open(container)
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
useTerminalUiBindings({
container,
term: t,
cleanups,
handlePointerDown,
handleLinkClick,
})
focusTerminal()
@@ -384,8 +430,10 @@ export const Terminal = (props: TerminalProps) => {
scheduleSize(size.cols, size.rows)
})
cleanups.push(() => disposeIfDisposable(onResize))
const connectionID = connection()
const onData = t.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
if (ws?.readyState !== WebSocket.OPEN) return
ws.send(frameInput(connectionID, data))
})
cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
@@ -402,20 +450,28 @@ export const Terminal = (props: TerminalProps) => {
cleanups.push(() => window.removeEventListener("resize", handleResize))
}
if (restore && restoreSize) {
t.write(restore, () => {
fit.fit()
scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
const write = (data: string) =>
new Promise<void>((resolve) => {
if (!output) {
resolve()
return
}
output.push(data)
output.flush(resolve)
})
if (restore && restoreSize) {
await write(restore)
fit.fit()
scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
} else {
fit.fit()
scheduleSize(t.cols, t.rows)
if (restore) {
t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
})
await write(restore)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
}
startResize()
}
@@ -424,75 +480,77 @@ export const Terminal = (props: TerminalProps) => {
// console.log("Scroll position:", ydisp)
// })
const once = { value: false }
let closing = false
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.searchParams.set("connection", connectionID)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? ""
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
const handleOpen = () => {
local.onConnect?.()
scheduleSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
if (disposed) return
if (closing) return
if (event.data instanceof ArrayBuffer) {
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
if (bytes[0] === FRAME_META) {
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown; connection?: unknown }
if (typeof meta?.connection === "string" && meta.connection !== connectionID) return
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
return
}
const frame = frameOutput(bytes, decoder)
if (!frame) return
if (frame.connection !== connectionID) return
if (!frame.data) return
output?.push(frame.data)
cursor += frame.data.length
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
cursor += data.length
if (typeof event.data === "string") {
debugTerminal("ignoring unframed websocket output")
}
}
socket.addEventListener("message", handleMessage)
cleanups.push(() => socket.removeEventListener("message", handleMessage))
const handleError = (error: Event) => {
if (disposed) return
if (closing) return
if (once.value) return
once.value = true
console.error("WebSocket error:", error)
local.onConnectError?.(error)
}
socket.addEventListener("error", handleError)
cleanups.push(() => socket.removeEventListener("error", handleError))
const handleClose = (event: CloseEvent) => {
if (disposed) return
if (closing) return
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
// For other codes (network issues, server restart), trigger error handler
if (event.code !== 1000) {
@@ -502,7 +560,15 @@ export const Terminal = (props: TerminalProps) => {
}
}
socket.addEventListener("close", handleClose)
cleanups.push(() => socket.removeEventListener("close", handleClose))
cleanups.push(() => {
closing = true
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
})
}
void run().catch((err) => {
@@ -520,9 +586,19 @@ export const Terminal = (props: TerminalProps) => {
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
}
if (!output) {
finalize()
return
}
output.flush(finalize)
})
return (

View File

@@ -1,6 +1,6 @@
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
@@ -43,6 +43,7 @@ export function Titlebar() {
const theme = useTheme()
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -171,9 +172,10 @@ export function Titlebar() {
<IconButton
icon="menu"
variant="ghost"
class="size-8 rounded-md"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
@@ -182,13 +184,14 @@ export function Titlebar() {
<IconButton
icon="menu"
variant="ghost"
class="size-8 rounded-md"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-3 shrink-0">
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
@@ -197,7 +200,7 @@ export function Titlebar() {
>
<Button
variant="ghost"
class="group/sidebar-toggle size-6 p-0"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
@@ -205,56 +208,77 @@ export function Titlebar() {
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.sidebar.opened() ? "layout-left-full" : "layout-left"}
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
class="group-hover/sidebar-toggle:hidden"
/>
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
<Icon
size="small"
name={layout.sidebar.opened() ? "layout-left" : "layout-left-full"}
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center gap-1 shrink-0">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="arrow-left"
class="size-6 p-0"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="arrow-right"
class="size-6 p-0"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon="new-session"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
/>
</TooltipKeybind>
</Show>
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</div>
</div>
<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">
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-6": !windows(),
"pr-2": !windows(),
}}
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
<div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" />

View File

@@ -11,7 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id
@@ -316,8 +316,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const isPalette = palette().has(sig)
const option = keymap().get(sig)
const modified = event.ctrlKey || event.metaKey || event.altKey
const isTab = event.key === "Tab"
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified) return
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab)
return
if (isPalette) {
event.preventDefault()

View File

@@ -1,10 +1,16 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js"
import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { usePlatform } from "./platform"
import { useServer } from "./server"
const abortError = z.object({
name: z.literal("AbortError"),
})
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: () => {
@@ -12,20 +18,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const abort = new AbortController()
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
const auth = (() => {
if (!password) return
if (!server.isLocal()) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventFetch = (() => {
if (!platform.fetch) return
if (!platform.fetch || !server.current) return
try {
const url = new URL(server.url)
const url = new URL(server.current.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
@@ -33,11 +29,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}
})()
const eventSdk = createOpencodeClient({
baseUrl: server.url,
const currentServer = server.current
if (!currentServer) throw new Error("No server available")
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
headers: eventFetch ? undefined : auth,
server: currentServer.http,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
@@ -93,23 +91,51 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = (error: unknown) => abortError.safeParse(error).success
let attempt: AbortController | undefined
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
void (async () => {
while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.url,
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
@@ -130,14 +156,18 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
await wait(0)
}
} catch (error) {
if (!streamErrorLogged) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.url,
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted) return
@@ -145,17 +175,43 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}
})().finally(flush)
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility)
}
onCleanup(() => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
abort.abort()
flush()
})
const sdk = createOpencodeClient({
baseUrl: server.url,
const sdk = createSdkForServer({
server: server.current.http,
fetch: platform.fetch,
throwOnError: true,
})
return { url: server.url, client: sdk, event: emitter }
return {
url: currentServer.http.url,
client: sdk,
event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error("Server not available")
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
}
},
})

View File

@@ -30,7 +30,6 @@ describe("pickDirectoriesToEvict", () => {
describe("loadRootSessionsWithFallback", () => {
test("uses limited roots query when supported", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({
directory: "dir",
@@ -39,20 +38,15 @@ describe("loadRootSessionsWithFallback", () => {
calls.push(query)
return { data: [] }
},
onFallback: () => {
fallback += 1
},
})
expect(result.data).toEqual([])
expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
expect(fallback).toBe(0)
})
test("falls back to full roots query on limited-query failure", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({
directory: "dir",
@@ -62,9 +56,6 @@ describe("loadRootSessionsWithFallback", () => {
if (query.limit) throw new Error("unsupported")
return { data: [] }
},
onFallback: () => {
fallback += 1
},
})
expect(result.data).toEqual([])
@@ -73,7 +64,6 @@ describe("loadRootSessionsWithFallback", () => {
{ directory: "dir", roots: true, limit: 25 },
{ directory: "dir", roots: true },
])
expect(fallback).toBe(1)
})
})

View File

@@ -1,46 +1,50 @@
import {
type Config,
type Path,
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
createOpencodeClient,
import type {
Config,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
ProviderListResponse,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
import { useGlobalSDK } from "./global-sdk"
import type { InitError } from "../pages/error"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import {
createContext,
createEffect,
untrack,
getOwner,
useContext,
Match,
onCleanup,
onMount,
type ParentProps,
Switch,
Match,
untrack,
useContext,
} from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import { createRefreshQueue } from "./global-sync/queue"
import { createChildStoreManager } from "./global-sync/child-store"
import { trimSessions } from "./global-sync/session-trim"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { sanitizeProject } from "./global-sync/utils"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
type GlobalStore = {
ready: boolean
error?: InitError
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
@@ -53,14 +57,6 @@ function errorMessage(error: unknown) {
return "Unknown error"
}
function setDevStats(value: {
activeDirectoryStores: number
evictions: number
loadSessionsFullFetchFallback: number
}) {
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
}
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
@@ -68,12 +64,7 @@ function createGlobalSync() {
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
const stats = {
evictions: 0,
loadSessionsFallback: 0,
}
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
@@ -87,19 +78,25 @@ function createGlobalSync() {
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
reload: undefined,
})
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
setDevStats({
activeDirectoryStores,
evictions: stats.evictions,
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
})
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
setGlobalStore(
"session_todo",
produce((draft) => {
delete draft[sessionID]
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
}
const paused = () => untrack(() => globalStore.reload) !== undefined
@@ -112,11 +109,6 @@ function createGlobalSync() {
const children = createChildStoreManager({
owner,
markStats: updateStats,
incrementEvictions: () => {
stats.evictions += 1
updateStats(Object.keys(children.children).length)
},
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => {
@@ -132,9 +124,7 @@ function createGlobalSync() {
const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory)
if (cached) return cached
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
const sdk = globalSDK.createClient({
directory,
throwOnError: true,
})
@@ -174,7 +164,10 @@ function createGlobalSync() {
const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
const next = trimSessions(store.session, {
limit: store.limit,
permission: store.permission,
})
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
}
@@ -187,10 +180,6 @@ function createGlobalSync() {
directory,
limit,
list: (query) => globalSDK.client.session.list(query),
onFallback: () => {
stats.loadSessionsFallback += 1
updateStats(Object.keys(children.children).length)
},
})
.then((x) => {
const nonArchived = (x.data ?? [])
@@ -199,10 +188,17 @@ function createGlobalSync() {
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit
const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
const sessions = trimSessions([...nonArchived, ...childSessions], {
limit,
permission: store.permission,
})
setStore(
"sessionTotal",
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
estimateRootSessionTotal({
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
)
setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit })
@@ -270,6 +266,11 @@ function createGlobalSync() {
setGlobalStore("project", next)
},
})
if (event.type === "server.connected" || event.type === "global.disposed") {
for (const directory of Object.keys(children.children)) {
queue.push(directory)
}
}
return
}
@@ -283,6 +284,7 @@ function createGlobalSync() {
store,
setStore,
push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(directory),
loadLsp: () => {
sdkFor(directory)
@@ -306,7 +308,9 @@ function createGlobalSync() {
await bootstrapGlobal({
globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"),
setGlobalStore,
})
@@ -353,6 +357,9 @@ function createGlobalSync() {
bootstrap,
updateConfig,
project: projectApi,
todo: {
set: setSessionTodo,
},
}
}

View File

@@ -1,25 +1,29 @@
import {
type Config,
type Path,
type PermissionRequest,
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
createOpencodeClient,
import type {
Config,
OpencodeClient,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
ProviderListResponse,
QuestionRequest,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils"
import type { State, VcsCache } from "./types"
import { cmp, normalizeProviderList } from "./utils"
type GlobalStore = {
ready: boolean
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
@@ -27,7 +31,7 @@ type GlobalStore = {
}
export async function bootstrapGlobal(input: {
globalSDK: ReturnType<typeof createOpencodeClient>
globalSDK: OpencodeClient
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string
@@ -106,13 +110,13 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
export async function bootstrapDirectory(input: {
directory: string
sdk: ReturnType<typeof createOpencodeClient>
sdk: OpencodeClient
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
}) {
input.setStore("status", "loading")
if (input.store.status !== "complete") input.setStore("status", "loading")
const blockingRequests = {
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),

View File

@@ -17,8 +17,6 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({
owner,
markStats() {},
incrementEvictions() {},
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},

View File

@@ -17,8 +17,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
export function createChildStoreManager(input: {
owner: Owner
markStats: (activeDirectoryStores: number) => void
incrementEvictions: () => void
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
@@ -102,7 +100,6 @@ export function createChildStoreManager(input: {
}
delete children[directory]
input.onDispose(directory)
input.markStats(Object.keys(children).length)
return true
}
@@ -120,7 +117,6 @@ export function createChildStoreManager(input: {
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directory)) continue
input.incrementEvictions()
}
}
@@ -200,7 +196,6 @@ export function createChildStoreManager(input: {
})
runWithOwner(input.owner, init)
input.markStats(Object.keys(children).length)
}
mark(directory)
const childStore = children[directory]

View File

@@ -116,6 +116,20 @@ describe("applyGlobalEvent", () => {
expect(refreshCount).toBe(1)
})
test("handles server.connected by triggering refresh", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "server.connected" },
project: [],
refresh: () => {
refreshCount += 1
},
setGlobalProject() {},
})
expect(refreshCount).toBe(1)
})
})
describe("applyDirectoryEvent", () => {

View File

@@ -20,7 +20,7 @@ export function applyGlobalEvent(input: {
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
input.refresh()
return
}
@@ -39,7 +39,12 @@ export function applyGlobalEvent(input: {
})
}
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
function cleanupSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
@@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<St
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return
setStore(
produce((draft) => {
@@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: {
directory: string
loadLsp: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
}) {
const event = input.event
switch (event.type) {
@@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: {
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {

View File

@@ -9,7 +9,6 @@ export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
limited: true,
} as const
} catch {
input.onFallback()
const result = await input.list({ directory: input.directory, roots: true })
return {
data: result.data,

View File

@@ -119,7 +119,6 @@ export type RootLoadArgs = {
directory: string
limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
onFallback: () => void
}
export type RootLoadResult = {

View File

@@ -174,6 +174,10 @@ function detectLocale(): Locale {
return "en"
}
function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
}
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
init: () => {
@@ -184,15 +188,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
}),
)
const locale = createMemo<Locale>(() =>
LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
)
createEffect(() => {
const current = locale()
if (store.locale === current) return
setStore("locale", current)
})
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
const dict = createMemo<Dictionary>(() => DICT[locale()])
@@ -213,7 +209,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label,
t,
setLocale(next: Locale) {
setStore("locale", next)
setStore("locale", normalizeLocale(next))
},
}
},

View File

@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
type PickerPaths = string | string[] | null
@@ -58,7 +58,7 @@ export type Platform = {
fetch?: typeof fetch
/** Get the configured default server URL (platform-specific) */
getDefaultServerUrl?(): Promise<string | null> | string | null
getDefaultServerUrl?(): Promise<string | null>
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void

View File

@@ -1,9 +1,8 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
@@ -12,14 +11,11 @@ type SDKEventMap = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: Accessor<string> }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const directory = createMemo(props.directory)
const client = createMemo(() =>
createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
globalSDK.createClient({
directory: directory(),
throwOnError: true,
}),
@@ -45,6 +41,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get url() {
return globalSDK.url
},
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
}
},
})

View File

@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
@@ -15,92 +15,126 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "")
}
export function serverDisplayName(url: string) {
if (!url) return ""
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
export function serverDisplayName(conn?: ServerConnection.Any) {
if (!conn) return ""
if (conn.displayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
function projectsKey(url: string) {
if (!url) return ""
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
if (key === "sidecar") return "local"
if (isLocalHost(key)) return "local"
return key
}
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
return url
}
export namespace ServerConnection {
type Base = { displayName?: string }
export type HttpBase = {
url: string
username?: string
password?: string
}
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
} & Base
export type Sidecar = {
type: "sidecar"
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
distro: string
}
) &
Base
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
} & Base
export type Any =
| Http
// All these are desktop-only
| (Sidecar | Ssh)
export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
}
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
list: [] as string[],
currentSidecarUrl: "",
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
const allServers = createMemo((): Array<ServerConnection.Any> => {
const servers = [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
]
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
return [...deduped.values()]
})
const [state, setState] = createStore({
active: "",
active: props.defaultServer,
healthy: undefined as boolean | undefined,
})
const healthy = () => state.healthy
const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
function reconcileStartup() {
const fallback = defaultUrl()
if (!fallback) return
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
if (!props.isSidecar) {
batch(() => {
setStore("list", list)
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
setState("active", fallback)
})
return
}
const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
setStore("list", nextList)
setStore("currentSidecarUrl", fallback)
setState("active", fallback)
})
}
function updateServerList(url: string, remove = false) {
if (remove) {
const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
batch(() => {
setStore("list", list)
setState("active", next)
})
return
}
batch(() => {
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
setState("active", url)
})
}
function startHealthPolling(url: string) {
function startHealthPolling(conn: ServerConnection.Any) {
let alive = true
let busy = false
const run = () => {
if (busy) return
busy = true
void check(url)
void check(conn)
.then((next) => {
if (!alive) return
setState("healthy", next)
@@ -118,59 +152,73 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
}
function setActive(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
setState("active", url)
function setActive(input: ServerConnection.Key) {
if (state.active !== input) setState("active", input)
}
function add(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
updateServerList(url)
return batch(() => {
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
const conn: ServerConnection.Http = { type: "http", http }
setState("active", ServerConnection.key(conn))
return conn
})
}
function remove(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
updateServerList(url, true)
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => x !== key)
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
}
})
}
createEffect(() => {
if (!ready()) return
if (state.active) return
reconcileStartup()
})
const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
createEffect(() => {
const url = state.active
if (!url) return
const current_ = current()
if (!current_) return
setState("healthy", undefined)
onCleanup(startHealthPolling(url))
onCleanup(startHealthPolling(current_))
})
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const isLocal = createMemo(() => origin() === "local")
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => {
const c = current()
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
})
return {
ready: isReady,
healthy,
isLocal,
get url() {
get key() {
return state.active
},
get name() {
return serverDisplayName(state.active)
return serverDisplayName(current())
},
get list() {
return store.list
return allServers()
},
get current() {
return current()
},
setActive,
add,

View File

@@ -85,6 +85,7 @@ const monoFonts: Record<string, string> = {
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
}
export function monoFontFamily(font: string | undefined) {

View File

@@ -289,12 +289,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
if (store.todo[sessionID] !== undefined) return
const existing = store.todo[sessionID]
if (existing !== undefined) {
if (globalSync.data.session_todo[sessionID] === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
}
const cached = globalSync.data.session_todo[sessionID]
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},

View File

@@ -1,11 +1,14 @@
// @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -103,16 +106,26 @@ const platform: Platform = {
forward,
restart,
notify,
getDefaultServerUrl: readDefaultServerUrl,
getDefaultServerUrl: async () => readDefaultServerUrl(),
setDefaultServerUrl: writeDefaultServerUrl,
}
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface />
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
</AppBaseProviders>
</PlatformProvider>
),

View File

@@ -63,6 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
"command.model.variant.cycle": "تغيير جهد التفكير",
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل",
@@ -206,9 +208,11 @@ export const dict = {
"common.attachment": "مرفق",
"prompt.placeholder.shell": "أدخل أمر shell...",
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
"prompt.placeholder.simple": "اسأل أي شيء...",
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc للخروج",
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
@@ -447,6 +451,9 @@ export const dict = {
"session.messages.loading": "جارٍ تحميل الرسائل...",
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
@@ -557,6 +564,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "تنبيه 01",
"sound.option.alert02": "تنبيه 02",
"sound.option.alert03": "تنبيه 03",

View File

@@ -63,6 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Mudar para o agente anterior",
"command.model.variant.cycle": "Alternar nível de raciocínio",
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"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",
@@ -206,9 +208,11 @@ export const dict = {
"common.attachment": "anexo",
"prompt.placeholder.shell": "Digite comando do shell...",
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
"prompt.placeholder.simple": "Pergunte qualquer coisa...",
"prompt.placeholder.summarizeComments": "Resumir comentários…",
"prompt.placeholder.summarizeComment": "Resumir comentário…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc para sair",
"prompt.example.1": "Corrigir um TODO no código",
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",
@@ -450,6 +454,9 @@ export const dict = {
"session.messages.loading": "Carregando mensagens...",
"session.messages.jumpToLatest": "Ir para a mais recente",
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
"session.todo.title": "Tarefas",
"session.todo.collapse": "Recolher",
"session.todo.expand": "Expandir",
"session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
"session.new.worktree.create": "Criar novo worktree",
@@ -563,6 +570,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
"sound.option.alert03": "Alerta 03",

View File

@@ -69,6 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta",
"command.model.variant.cycle": "Promijeni nivo razmišljanja",
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
"command.workspace.toggle": "Prikaži/sakrij radne prostore",
@@ -224,9 +226,11 @@ export const dict = {
"prompt.placeholder.shell": "Unesi shell naredbu...",
"prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"',
"prompt.placeholder.simple": "Pitaj bilo šta...",
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc za izlaz",
"prompt.example.1": "Popravi TODO u bazi koda",
@@ -505,6 +509,9 @@ export const dict = {
"session.messages.jumpToLatest": "Idi na najnovije",
"session.context.addToContext": "Dodaj {{selection}} u kontekst",
"session.todo.title": "Zadaci",
"session.todo.collapse": "Sažmi",
"session.todo.expand": "Proširi",
"session.new.worktree.main": "Glavna grana",
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
@@ -631,6 +638,7 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Upozorenje 01",
"sound.option.alert02": "Upozorenje 02",
"sound.option.alert03": "Upozorenje 03",

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