Compare commits

..

85 Commits

Author SHA1 Message Date
opencode-agent[bot]
3920bc5ee4 chore: update nix node_modules hashes 2026-03-02 03:22:59 +00:00
opencode-agent[bot]
656dcb3057 Apply PR #15637: Animation Smorgasbord 2026-03-02 03:10:26 +00:00
opencode-agent[bot]
232adb87f4 Apply PR #15487: core: make account login upgrades safe while adding multi-account workspace auth 2026-03-02 03:10:26 +00:00
opencode-agent[bot]
b17966bf15 Apply PR #15322: desktop: new-session deeplink 2026-03-02 03:10:25 +00:00
opencode-agent[bot]
0e7fb80ca9 Apply PR #15282: show scrollbar by default 2026-03-02 03:09:51 +00:00
opencode-agent[bot]
7f1d12a49f Apply PR #15266: feat(app): changelog with PR links 2026-03-02 03:09:50 +00:00
opencode-agent[bot]
34d1b81e53 Apply PR #15250: feat(app): view archived sessions & unarchive 2026-03-02 03:09:34 +00:00
opencode-agent[bot]
606d5003fd Apply PR #15013: refactor: replace Bun.sleep with node timers 2026-03-02 03:09:33 +00:00
opencode-agent[bot]
dd87cf8025 Apply PR #15012: refactor(opencode): replace Bun.which with npm which 2026-03-02 03:09:33 +00:00
opencode-agent[bot]
0a988aa21d Apply PR #14974: Upgrade opentui to v0.1.84 and activate markdown renderable by default 2026-03-02 03:09:32 +00:00
opencode-agent[bot]
67a152c91a Apply PR #14677: feat: add experimental hashline edit mode with dual-schema support 2026-03-02 03:09:32 +00:00
opencode-agent[bot]
2931feaa3d Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-03-02 03:09:32 +00:00
opencode-agent[bot]
1644add49b Apply PR #14307: fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering 2026-03-02 03:09:31 +00:00
opencode-agent[bot]
0a37883133 Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-03-02 03:09:10 +00:00
opencode-agent[bot]
9ddb5aa8d6 Apply PR #12022: feat: update tui model dialog to utilize model family to reduce noise in list 2026-03-02 03:09:10 +00:00
Kit Langton
87b16b2681 fix(ui): use .finished.then() for motion AnimationPlaybackControls 2026-03-01 21:51:04 -05:00
Kit Langton
d74ae84d8e merge upstream/dev, resolve conflicts in message-timeline and session-turn 2026-03-01 21:50:28 -05:00
Kit Langton
f8a630f9c7 fix(ui): keep TextShimmer mounted for smooth transitions, move shell submessage fade to JS
- Fix 6 instances where TextShimmer was destroyed/recreated via <Show>
  swap instead of toggling active prop (bash, webfetch, edit, write,
  context tools, basic-tool fallback)
- Move shell submessage opacity/blur from CSS transitions to animate()
  so they respect the animate prop and don't fire on page load
- Remove data-visible attribute pattern, all animation now driven by
  Motion's animate() when animate=true
2026-03-01 21:32:44 -05:00
Kit Langton
f0f2e523cb feat(ui): spring width animation for shell submessage, replace TextOdometer with TextReveal
- Shell submessage now uses Motion's animate() with width: "auto" for
  spring-driven width reveal instead of CSS grid 0fr→1fr transition
- Skip animation on page load (sawPending flag), only animate live tool calls
- Fix baseline alignment with overflow: clip instead of overflow: hidden
- Replace TextOdometer with TextReveal in production (session-turn, todo-dock)
- Remove TextOdometer component, CSS, and stories
- Add TextReveal to thinking-heading story
- Update shell submessage story with visualDuration/bounce sliders
2026-03-01 21:29:24 -05:00
Kit Langton
5f9c43dee6 feat(ui): spring animations for composer mode toggle and tray transitions
Add spring-based animations to the prompt input composer:
- Mode toggle (shell/conversation) indicator uses spring cubic-bezier
- Submit and plus buttons animate with individual scale, opacity, and blur
- Tray items (agent, model, variant selectors) crossfade with spring
- Shell label animates in/out opposite to normal mode controls
- Add TextStrikethrough component for todo item completion
- Add truncate support to TextReveal
- Wire up count mask/height/width props through composer region
2026-03-01 20:39:43 -05:00
Kit Langton
ae1d5da6ca wip: checkpoint todo panel + odometer motion work 2026-03-01 19:48:19 -05:00
Alex Yaroshuk
fe0f298293 fix i18n 2026-03-01 06:00:24 +08:00
Alex Yaroshuk
29d90056e9 Merge branch 'dev' into feat/changelog 2026-03-01 05:48:41 +08:00
Alex Yaroshuk
276d60e82a fix ar.ts, sync dialog-select-file.tsx to fix typecheck 2026-03-01 05:45:07 +08:00
Alex Yaroshuk
9ea36ccd9d sync time.ts specific code to fix typecheck 2026-03-01 05:29:04 +08:00
Alex Yaroshuk
b9ca79f3b6 refactor: use createResource + Suspense instead of manual signals, remove unused imports 2026-03-01 03:11:15 +08:00
Kit Langton
8cafdce25e chore(storybook): simplify animated count story locales 2026-02-27 21:40:37 -05:00
Kit Langton
e39cbc0e5b refactor(ui): simplify text shimmer API and story controls 2026-02-27 21:32:34 -05:00
Alex Yaroshuk
323e7a36da (sync) update getRealtiveTime call to use the new language arg 2026-02-28 10:30:46 +08:00
Kit Langton
031d872c8a tweak(ui): shimmering titles and animated counts 2026-02-27 21:29:18 -05:00
Alex Yaroshuk
9faaa6130d Merge branch 'dev' into feat/unarchive 2026-02-28 09:45:55 +08:00
Brendan Allan
14acf269aa Merge branch 'dev' into brendan/new-session-deeplink 2026-02-27 21:28:20 +08:00
Sebastian Herrlinger
4051bb0b50 table options 2026-02-27 10:22:09 +01:00
Sebastian Herrlinger
3cde99f65e upgrade opentui 2026-02-27 10:22:09 +01:00
Sebastian Herrlinger
bd545f6f7a upgrade opentui 2026-02-27 10:22:09 +01:00
Sebastian Herrlinger
fa4bd00f54 use markdown renderable by default 2026-02-27 10:22:09 +01:00
Sebastian Herrlinger
903bdd4066 upgrade opentui 2026-02-27 10:22:09 +01:00
Shoubhit Dash
ab44597018 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 11:52:55 +05:30
Brendan Allan
1d0d427b5f desktop: new-session deeplink 2026-02-27 12:28:56 +08:00
Shoubhit Dash
aec95c4d10 stabilize hashline routing and anchors 2026-02-27 09:45:06 +05:30
Shoubhit Dash
b2c82cb897 Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-27 08:55:43 +05:30
Sebastian Herrlinger
12dfd7e6a8 show scrollbar by default 2026-02-26 21:41:01 +01:00
Alex Yaroshuk
20905212f9 Merge branch 'clean-dev' into feat/changelog 2026-02-27 02:07:31 +08:00
Alex Yaroshuk
76cda30896 use early return instead of 'else' in index.ts 2026-02-27 00:36:50 +08:00
Alex Yaroshuk
4f740306f0 fix layout 2026-02-26 23:47:23 +08:00
Alex Yaroshuk
c7e9851826 wip: unarchive 2026-02-26 03:26:47 +08:00
Dax Raad
bf53e1c24b test(opencode): tolerate Windows PATH extension casing 2026-02-25 00:00:27 -05:00
Dax Raad
50004d1f94 refactor: replace Bun.sleep with node timers 2026-02-24 23:54:49 -05:00
Dax Raad
acd7c5ad55 core: add tests for command resolution to ensure reliable tool discovery across platforms 2026-02-24 23:50:11 -05:00
Dax Raad
cf54b544e3 refactor(opencode): replace Bun.which with npm which 2026-02-24 23:42:25 -05:00
Shoubhit Dash
52b42258fa Merge branch 'dev' into feat/hashline-edit-experimental-v2 2026-02-23 15:16:20 +05:30
Shoubhit Dash
3026a005b6 test: reorder hashline config test for beta merge 2026-02-23 10:05:12 +05:30
Shoubhit Dash
a6f802d7fe fix: align codex prompt with edit routing 2026-02-22 22:39:03 +05:30
Shoubhit Dash
9ef803be82 feat: enable hashline by default 2026-02-22 22:31:33 +05:30
Shoubhit Dash
ce5c827a6e chore: remove local opencode config flags 2026-02-22 19:46:59 +05:30
Shoubhit Dash
56decd79db feat: add experimental hashline edit mode 2026-02-22 19:40:34 +05:30
MakonnenMak
fc258ea74f fix: remove as any type cast in processor exit logic 2026-02-20 13:20:10 -05:00
Makonnen
abd9e195ac fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering
When the client clock is ahead of the server, user message IDs (generated
client-side) sort after assistant message IDs (generated server-side).
This broke the prompt loop exit check and the UI message pairing logic.

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

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

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Alex Yaroshuk
d8bcfd90d3 trnslations, couple more fixes 2026-02-04 21:06:27 +08:00
Alex Yaroshuk
954d31903f fix ui 2026-02-04 20:50:55 +08:00
Alex Yaroshuk
1587d93b29 fixes 2026-02-04 20:35:06 +08:00
Alex Yaroshuk
d364c43916 style fixes 2026-02-04 20:15:02 +08:00
Alex Yaroshuk
72eec20437 add links 2026-02-04 17:12:41 +08:00
Alex Yaroshuk
4503bde1cc fix styling, add scrollbar 2026-02-04 16:59:00 +08:00
Alex Yaroshuk
1abc228e95 add timetsamps 2026-02-04 16:52:19 +08:00
Alex Yaroshuk
991e823039 style fixes 2026-02-04 16:39:01 +08:00
Alex Yaroshuk
62fa5c1314 fix styles 2026-02-04 16:28:09 +08:00
Alex Yaroshuk
93b9e47c05 changelog v1 2026-02-04 15:54:47 +08:00
Aiden Cline
bb4d978684 feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-03 15:48:40 -06:00
146 changed files with 8628 additions and 868 deletions

View File

@@ -304,8 +304,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.81",
"@opentui/solid": "0.1.81",
"@opentui/core": "0.1.84",
"@opentui/solid": "0.1.84",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -342,6 +342,7 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -364,6 +365,7 @@
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
@@ -423,17 +425,18 @@
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "^10.2.10",
"@storybook/addon-docs": "^10.2.10",
"@storybook/addon-links": "^10.2.10",
"@storybook/addon-onboarding": "^10.2.10",
"@storybook/addon-vitest": "^10.2.10",
"@storybook/addon-a11y": "^10.2.13",
"@storybook/addon-docs": "^10.2.13",
"@storybook/addon-links": "^10.2.13",
"@storybook/addon-onboarding": "^10.2.13",
"@storybook/addon-vitest": "^10.2.13",
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@types/react": "18.0.25",
"react": "18.2.0",
"solid-js": "catalog:",
"storybook": "^10.2.10",
"storybook": "^10.2.13",
"storybook-solidjs-vite": "^10.0.9",
"typescript": "catalog:",
"vite": "catalog:",
@@ -461,6 +464,9 @@
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.3",
"motion-dom": "12.34.3",
"motion-utils": "12.29.2",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
@@ -1341,21 +1347,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.81", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.81", "@opentui/core-darwin-x64": "0.1.81", "@opentui/core-linux-arm64": "0.1.81", "@opentui/core-linux-x64": "0.1.81", "@opentui/core-win32-arm64": "0.1.81", "@opentui/core-win32-x64": "0.1.81", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ooFjkkQ80DDC4X5eLvH8dBcLAtWwGp9RTaWsaeWet3GOv4N0SDcN8mi1XGhYnUlTuxmofby5eQrPegjtWHODlA=="],
"@opentui/core": ["@opentui/core@0.1.84", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.84", "@opentui/core-darwin-x64": "0.1.84", "@opentui/core-linux-arm64": "0.1.84", "@opentui/core-linux-x64": "0.1.84", "@opentui/core-win32-arm64": "0.1.84", "@opentui/core-win32-x64": "0.1.84", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-UdPD/sldUSiIu588l45lQq6q09zLW8L4GOgkA4E3fyExIlIgOrpIhFSKqZwbVCbe53dQOybHVUdKob64Xxhm4w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.81", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I3Ry5JbkSQXs2g1me8yYr0v3CUcIIfLHzbWz9WMFla8kQDSa+HOr8IpZbqZDeIFgOVzolAXBmZhg0VJI3bZ7MA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.84", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MpLdRYd2zLJP7IcWHu1SYXFet6ZfOzTUW12iaexlUL4DkAU+P5mO1v/OdLlZrmXVj/FD3BqDM5SUIXNE0+A46Q=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.81", "", { "os": "darwin", "cpu": "x64" }, "sha512-CrtNKu41D6+bOQdUOmDX4Q3hTL6p+sT55wugPzbDq7cdqFZabCeguBAyOlvRl2g2aJ93kmOWW6MXG0bPPklEFg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.84", "", { "os": "darwin", "cpu": "x64" }, "sha512-eEFzEdo/WVVjbpwY2pnQBxkpqsWyGLHNv3kFc0RHvZ3ARIW2qb/Jqo9O9uUbR6th7H77V1NRJ0a6gvby48Oofg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.81", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJw9zmJop9WiMvtT07nSrfBLPLqskxL6xfV3GNft0mSYV+C3hdJ0qkiczGSHUX/6V7fmouM84RWwmY53Rb6hYQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.84", "", { "os": "linux", "cpu": "arm64" }, "sha512-AYfG9iLWavfVYZYbNfEZkCimqk8Uq8EzvHKwRkbP24XVZO7eALztZ6PWl2nJqDrw+rYBVSJg0uS6ON+M7NxyBA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.81", "", { "os": "linux", "cpu": "x64" }, "sha512-Rj2AFIiuWI0BEMIvh/Jeuxty9Gp5ZhLuQU7ZHJJhojKo/mpBpMs9X+5kwZPZya/tyR8uVDAVyB6AOLkhdRW5lw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.84", "", { "os": "linux", "cpu": "x64" }, "sha512-pLi3a3rqs1BMeCqj1GZm/Qi3zdwilQxaPEI/IeWc5qdzWInpGPHs3uadPJBAllEGRzvTmStMWI6iY6bdc98bng=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.81", "", { "os": "win32", "cpu": "arm64" }, "sha512-AiZB+mZ1cVr8plAPrPT98e3kw6D0OdOSe2CQYLgJRbfRlPqq3jl26lHPzDb3ZO2OR0oVGRPJvXraus939mvoiQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.84", "", { "os": "win32", "cpu": "arm64" }, "sha512-dcQruw9VyYACxukoB+iv8SskQxl5MMeY73uqvQ17dsnM2sfukT+c2MSgbgYImCKKixtlGXLcb5Weo36lQqI5AQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.81", "", { "os": "win32", "cpu": "x64" }, "sha512-l8R2Ni1CR4eHi3DTmSkEL/EjHAtOZ/sndYs3VVw+Ej2esL3Mf0W7qSO5S0YNBanz2VXZhbkmM6ERm9keH8RD3w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.84", "", { "os": "win32", "cpu": "x64" }, "sha512-KiCaIVtVnZYQza+vAW8TDbUMxBgd2thvjNOIJ03NmjMiiRnfmO55wA3Zh9vNuQ5PJEYI0awzXYFAZ7kaMsDQxA=="],
"@opentui/solid": ["@opentui/solid@0.1.81", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.81", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-QRjS0wPuIhBRdY8tpG3yprCM4ZnOxWWHTuaZ4hhia2wFZygf7Ome6EuZnLXmtuOQjkjCwu0if8Yik6toc6QylA=="],
"@opentui/solid": ["@opentui/solid@0.1.84", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.84", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-5koIQ9Nk5CU6/uVxjjWJ7jtFeSNkczbZoEzHLdUK/T2bqHSyuPSFBeliQ1hfOwrN2G5SKFqIB3U5+1EcHxDCCA=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1803,25 +1809,25 @@
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q=="],
"@storybook/addon-a11y": ["@storybook/addon-a11y@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-zuR1n1xgWoieEnr6E5xdTR40BI61IBQahgmsRpTvqRffL3mxAs5aFoORDmA5pZWI2LE9URdMkY85h218ijuLiw=="],
"@storybook/addon-docs": ["@storybook/addon-docs@10.2.10", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.2.10", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.2.10", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-2wIYtdvZIzPbQ5194M5Igpy8faNbQ135nuO5ZaZ2VuttqGr+IJcGnDP42zYwbAsGs28G8ohpkbSgIzVyJWUhPQ=="],
"@storybook/addon-docs": ["@storybook/addon-docs@10.2.13", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.2.13", "@storybook/icons": "^2.0.1", "@storybook/react-dom-shim": "10.2.13", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-puMxpJbt/CuodLIbKDxWrW1ZgADYomfNHWEKp2d2l2eJjp17rADx0h3PABuNbX+YHbJwYcDdqluSnQwMysFEOA=="],
"@storybook/addon-links": ["@storybook/addon-links@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" }, "optionalPeers": ["react"] }, "sha512-oo9Xx4/2OVJtptXKpqH4ySri7ZuBdiSOXlZVGejEfLa0Jeajlh/KIlREpGvzPPOqUVT7dSddWzBjJmJUyQC3ew=="],
"@storybook/addon-links": ["@storybook/addon-links@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.13" }, "optionalPeers": ["react"] }, "sha512-8wnAomGiHaUpNIc+lOzmazTrebxa64z9rihIbM/Q59vkOImHQNkGp7KP/qNgJA4GPTFtu8+fLjX2qCoAQPM0jQ=="],
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.2.10", "", { "peerDependencies": { "storybook": "^10.2.10" } }, "sha512-DkzZQTXHp99SpHMIQ5plbbHcs4EWVzWhLXlW+icA8sBlKo5Bwj540YcOApKbqB0m/OzWprsznwN7Kv4vfvHu4w=="],
"@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.2.13", "", { "peerDependencies": { "storybook": "^10.2.13" } }, "sha512-kw2GgIY67UR8YXKfuVS0k+mfWL1joNQHeSe5DlDL4+7qbgp9zfV6cRJ199BMdfRAQNMzQoxHgRUcAMAqs3Rkpw=="],
"@storybook/addon-vitest": ["@storybook/addon-vitest@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.2.10", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-U2oHw+Ar+Xd06wDTB74VlujhIIW89OHThpJjwgqgM6NWrOC/XLllJ53ILFDyREBkMwpBD7gJQIoQpLEcKBIEhw=="],
"@storybook/addon-vitest": ["@storybook/addon-vitest@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.2.13", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-qQD3xzxc31cQHS0loF9enGWi5sgA6zBTbaJ0HuSUNGO81iwfLSALh8L/1vrD5NfN2vlBeUMTsgv3EkCuLfe9EQ=="],
"@storybook/builder-vite": ["@storybook/builder-vite@10.2.10", "", { "dependencies": { "@storybook/csf-plugin": "10.2.10", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.2.10", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-Wd6CYL7LvRRNiXMz977x9u/qMm7nmMw/7Dow2BybQo+Xbfy1KhVjIoZ/gOiG515zpojSozctNrJUbM0+jH1jwg=="],
"@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.10", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g=="],
"@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.13", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.13", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-gUCR7PmyrWYj3dIJJgxOm25dcXFolPIUPmug3z90Aaon7YPXw3pUN+dNDx8KqDJqRK1WDIB4HaefgYZIm5V7iA=="],
"@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="],
"@storybook/icons": ["@storybook/icons@2.0.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg=="],
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.2.10", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.10" } }, "sha512-TmBrhyLHn8B8rvDHKk5uW5BqzO1M1T+fqFNWg88NIAJOoyX4Uc90FIJjDuN1OJmWKGwB5vLmPwaKBYsTe1yS+w=="],
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.2.13", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.2.13" } }, "sha512-ZSduoB10qTI0V9z22qeULmQLsvTs8d/rtJi03qbVxpPiMRor86AmyAaBrfhGGmWBxWQZpOGQQm6yIT2YLoPs7w=="],
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
@@ -2035,6 +2041,8 @@
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
@@ -3013,7 +3021,7 @@
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
@@ -3325,6 +3333,12 @@
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.3", "", { "dependencies": { "framer-motion": "^12.34.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw=="],
"motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -3897,7 +3911,7 @@
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
"storybook": ["storybook@10.2.10", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-N4U42qKgzMHS7DjqLz5bY4P7rnvJtYkWFCyKspZr3FhPUuy6CWOae3aYC2BjXkHrdug0Jyta6VxFTuB1tYUKhg=="],
"storybook": ["storybook@10.2.13", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./dist/bin/dispatcher.js" }, "sha512-heMfJjOfbHvL+wlCAwFZlSxcakyJ5yQDam6e9k2RRArB1veJhRnsjO6lO1hOXjJYrqxfHA/ldIugbBVlCDqfvQ=="],
"storybook-solidjs-vite": ["storybook-solidjs-vite@10.0.9", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.1", "@storybook/builder-vite": "^10.0.0", "@storybook/global": "^5.0.0", "vite-plugin-solid": "^2.11.8" }, "peerDependencies": { "solid-js": "^1.9.0", "storybook": "^0.0.0-0 || ^10.0.0", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["typescript"] }, "sha512-n6MwWCL9mK/qIaUutE9vhGB0X1I1hVnKin2NL+iVC5oXfAiuaABVZlr/1oEeEypsgCdyDOcbEbhJmDWmaqGpPw=="],
@@ -4215,7 +4229,7 @@
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -4721,6 +4735,8 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"@storybook/builder-vite/@storybook/csf-plugin": ["@storybook/csf-plugin@10.2.10", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.2.10", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
@@ -4815,6 +4831,8 @@
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
@@ -4887,6 +4905,8 @@
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"motion/framer-motion": ["framer-motion@12.34.3", "", { "dependencies": { "motion-dom": "^12.34.3", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q=="],
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
@@ -5387,6 +5407,8 @@
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],

View File

@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await Bun.sleep(300)
await sleep(300)
} while (retry++ < 30)
if (!connected) {

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2XLuizbG90QDUQL+1M90XxfVZxjkIQ1cFYS46nnVO7g=",
"aarch64-linux": "sha256-hlckiGAtbpAlwgcE7KgzKKRq9T2FEOSq3Q1MhuHfZ2c=",
"aarch64-darwin": "sha256-V/8Kay+5bDb/BSVgBQhSMwzmRmkNGl3U0HFMVbVcMak=",
"x86_64-darwin": "sha256-duLDF88Q/hXK5jwBy4dVxMSiTTS0R4obp9MlTuOF/Pw="
"x86_64-linux": "sha256-H/ICMxxlRg8J6XSsmoq8fTZwczjqhhNOxkKf4F2ljeM=",
"aarch64-linux": "sha256-zHe48NVh80ZNBfn28OdgF394Re26L8YHp6iN03GVlNc=",
"aarch64-darwin": "sha256-CBLOgmRj9kpmaaSQ9ew4G6itZ1POHMlO+JqtSDLhjcA=",
"x86_64-darwin": "sha256-2Taz3Xx2DT+VHqz6ZvUcqmTw8BpeywwR6bhGhRHKrg4="
}
}

View File

@@ -9,6 +9,7 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",

View File

@@ -0,0 +1,50 @@
import type { Platform } from "@/context/platform"
const REPO = "anomalyco/opencode"
const GITHUB_API_URL = `https://api.github.com/repos/${REPO}/releases`
const PER_PAGE = 30
const CACHE_TTL = 1000 * 60 * 30
const CACHE_KEY = "opencode.releases"
type Release = {
tag: string
body: string
date: string
}
function loadCache() {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? JSON.parse(raw) : null
}
function saveCache(data: { releases: Release[]; timestamp: number }) {
localStorage.setItem(CACHE_KEY, JSON.stringify(data))
}
export async function fetchReleases(platform: Platform): Promise<{ releases: Release[] }> {
const now = Date.now()
const cached = loadCache()
if (cached && now - cached.timestamp < CACHE_TTL) {
return { releases: cached.releases }
}
const fetcher = platform.fetch ?? fetch
const res = await fetcher(`${GITHUB_API_URL}?per_page=${PER_PAGE}`, {
headers: { Accept: "application/vnd.github.v3+json" },
}).then((r) => (r.ok ? r.json() : Promise.reject(new Error("Failed to load"))))
const releases = (Array.isArray(res) ? res : []).map((r) => ({
tag: r.tag_name ?? "Unknown",
body: (r.body ?? "")
.replace(/#(\d+)/g, (_: string, id: string) => `[#${id}](https://github.com/anomalyco/opencode/pull/${id})`)
.replace(/@([a-zA-Z0-9_-]+)/g, (_: string, u: string) => `[@${u}](https://github.com/${u})`),
date: r.published_at ?? "",
}))
saveCache({ releases, timestamp: now })
return { releases }
}
export type { Release }

View File

@@ -0,0 +1,150 @@
.dialog-changelog {
min-height: 500px;
display: flex;
flex-direction: column;
}
.dialog-changelog [data-slot="dialog-body"] {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.dialog-changelog-list {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.dialog-changelog-list [data-slot="list-scroll"] {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-weak-base) transparent;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-track {
background: transparent;
border-radius: 5px;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb {
background: var(--border-weak-base);
border-radius: 5px;
border: 3px solid transparent;
background-clip: padding-box;
}
.dialog-changelog-list [data-slot="list-scroll"]::-webkit-scrollbar-thumb:hover {
background: var(--border-weak-base);
}
.dialog-changelog-header {
padding: 8px 12px 8px 8px;
display: flex;
align-items: baseline;
gap: 8px;
position: sticky;
top: 0;
z-index: 10;
background: var(--surface-raised-stronger-non-alpha);
}
.dialog-changelog-header::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 16px;
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.dialog-changelog-header[data-stuck="true"]::after {
opacity: 1;
}
.dialog-changelog-version {
font-size: 20px;
font-weight: 600;
}
.dialog-changelog-date {
font-size: 12px;
font-weight: 400;
color: var(--text-weak);
}
.dialog-changelog-list [data-slot="list-item"] {
margin-bottom: 32px;
padding: 0;
border: none;
background: transparent;
cursor: default;
display: block;
text-align: left;
}
.dialog-changelog-list [data-slot="list-item"]:hover {
background: transparent;
}
.dialog-changelog-list [data-slot="list-item"]:focus {
outline: none;
}
.dialog-changelog-list [data-slot="list-item"]:focus-visible {
outline: 2px solid var(--focus-base);
outline-offset: 2px;
}
.dialog-changelog-content {
padding: 0 8px 24px;
}
.dialog-changelog-markdown h2 {
border-bottom: 1px solid var(--border-weak-base);
padding-bottom: 4px;
margin: 32px 0 12px 0;
font-size: 14px;
font-weight: 500;
text-transform: capitalize;
}
.dialog-changelog-markdown h2:first-child {
margin-top: 16px;
}
.dialog-changelog-markdown a.external-link {
color: var(--text-interactive-base);
font-weight: 500;
}
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"],
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"],
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]
{
border-radius: 3px;
padding: 0 2px;
}
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/pull/"]:hover,
.dialog-changelog-markdown a.external-link[href^="https://github.com/anomalyco/opencode/issues/"]:hover,
.dialog-changelog-markdown a.external-link[href^="https://github.com/"]:hover
{
background: var(--surface-weak-base);
}

View File

@@ -0,0 +1,40 @@
import { createResource, Suspense, ErrorBoundary, Show } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { fetchReleases } from "@/api/releases"
import { ReleaseList } from "@/components/release-list"
export function DialogChangelog() {
const language = useLanguage()
const platform = usePlatform()
const [data] = createResource(() => fetchReleases(platform))
return (
<Dialog size="x-large" transition title="Changelog">
<div class="flex-1 min-h-0 flex flex-col">
<ErrorBoundary
fallback={(e) => (
<p class="text-text-weak p-6">
{e instanceof Error ? e.message : "Failed to load changelog"}
</p>
)}
>
<Suspense fallback={<p class="text-text-weak p-6">{language.t("common.loading")}...</p>}>
<Show
when={(data()?.releases.length ?? 0) > 0}
fallback={<p class="text-text-weak p-6">{language.t("common.noReleasesFound")}</p>}
>
<ReleaseList
releases={data()!.releases}
hasMore={false}
loadingMore={false}
onLoadMore={() => {}}
/>
</Show>
</Suspense>
</ErrorBoundary>
</div>
</Dialog>
)
}

View File

@@ -459,4 +459,4 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</List>
</Dialog>
)
}
}

View File

@@ -2,16 +2,25 @@ import { Component } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsArchive } from "./settings-archive"
import { DialogChangelog } from "@/components/dialog-changelog"
export const DialogSettings: Component = () => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
function handleShowChangelog() {
dialog.show(() => <DialogChangelog />)
}
return (
<Dialog size="x-large" transition>
@@ -47,11 +56,27 @@ export const DialogSettings: Component = () => {
</Tabs.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Tabs.SectionTitle>{language.t("settings.section.data")}</Tabs.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<Tabs.Trigger value="archive">
<Icon name="archive" />
{language.t("settings.archive.title")}
</Tabs.Trigger>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
<span>{language.t("app.name.desktop")}</span>
<span class="text-11-regular">v{platform.version}</span>
<button
class="text-11-regular text-text-weak hover:text-text-base self-start"
onClick={handleShowChangelog}
>
Changelog
</button>
</div>
</div>
</Tabs.List>
@@ -67,6 +92,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="archive" class="no-scrollbar">
<SettingsArchive />
</Tabs.Content>
</Tabs>
</Dialog>
)

View File

@@ -1,4 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
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"
@@ -253,6 +254,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
applyingHistory: false,
})
const buttonsSpring = useSpring(
() => (store.mode === "normal" ? 1 : 0),
{ visualDuration: 0.2, bounce: 0 },
)
const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
@@ -1250,10 +1256,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<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",
class="flex items-center gap-1"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<TooltipKeybind
@@ -1266,6 +1271,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
variant="ghost"
class="size-8 p-0"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1303,6 +1313,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
style={{
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
}}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1353,14 +1368,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<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"}>
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
<div
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
style={{
padding: "0 4px 0 8px",
opacity: 1 - buttonsSpring(),
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
filter: `blur(${buttonsSpring() * 2}px)`,
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
}}
>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<TooltipKeybind
placement="top"
gutter={4}
@@ -1374,7 +1396,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
variant="ghost"
/>
</TooltipKeybind>
@@ -1392,7 +1420,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px" }}
style={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
@@ -1421,7 +1455,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{
variant: "ghost",
size: "normal",
style: { height: "28px" },
style: {
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
},
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
@@ -1453,11 +1493,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
triggerStyle={{
height: "28px",
opacity: buttonsSpring(),
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
variant="ghost"
/>
</TooltipKeybind>
</Show>
</div>
</div>
<div class="shrink-0">
<RadioGroup

View File

@@ -0,0 +1,61 @@
import { Component } from "solid-js"
import { List } from "@opencode-ai/ui/list"
import { Markdown } from "@opencode-ai/ui/markdown"
import { Button } from "@opencode-ai/ui/button"
import { Tag } from "@opencode-ai/ui/tag"
import { useLanguage } from "@/context/language"
import { getRelativeTime } from "@/utils/time"
type Release = {
tag: string
body: string
date: string
}
interface ReleaseListProps {
releases: Release[]
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
}
export const ReleaseList: Component<ReleaseListProps> = (props) => {
const language = useLanguage()
return (
<List
items={props.releases}
key={(x) => x.tag}
search={false}
emptyMessage="No releases found"
loadingMessage={language.t("common.loading")}
class="flex-1 min-h-0 overflow-hidden flex flex-col [&_[data-slot=list-scroll]]:session-scroller [&_[data-slot=list-item]]:block [&_[data-slot=list-item]]:p-0 [&_[data-slot=list-item]]:border-0 [&_[data-slot=list-item]]:bg-transparent [&_[data-slot=list-item]]:text-left [&_[data-slot=list-item]]:cursor-default [&_[data-slot=list-item]]:hover:bg-transparent [&_[data-slot=list-item]]:focus:outline-none"
add={{
render: () =>
props.hasMore ? (
<div class="p-4 flex justify-center">
<Button variant="secondary" size="small" onClick={props.onLoadMore} loading={props.loadingMore}>
{language.t("common.loadMore")}
</Button>
</div>
) : null,
}}
>
{(item) => (
<div class="mb-8">
<div class="py-2 pr-3 pl-2 flex items-baseline gap-2 sticky top-0 z-10 bg-surface-raised-stronger-non-alpha">
<span class="text-[20px] font-semibold">{item.tag}</span>
<span class="text-xs text-text-weak">{item.date ? getRelativeTime(item.date, language.t) : ""}</span>
{item.tag === props.releases[0]?.tag && <Tag>{language.t("changelog.tag.latest")}</Tag>}
</div>
<div class="px-2 pb-2">
<Markdown
text={item.body}
class="prose prose-sm max-w-none text-text-base [&_h2]:border-b [&_h2]:border-border-weak-base [&_h2]:pb-1 [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-sm [&_h2]:font-medium [&_h2]:capitalize [&_h2:first-child]:mt-4 [&_a.external-link]:text-text-interactive-base [&_a.external-link]:font-medium"
/>
</div>
</div>
)}
</List>
)
}

View File

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

View File

@@ -0,0 +1,188 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { RadioGroup } from "@opencode-ai/ui/radio-group"
import { getFilename } from "@opencode-ai/util/path"
import { Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
import { useParams } from "@solidjs/router"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { getRelativeTime } from "@/utils/time"
import { decode64 } from "@/utils/base64"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { SessionSkeleton } from "@/pages/layout/sidebar-items"
type FilterScope = "all" | "current"
type ScopeOption = { value: FilterScope; label: "settings.archive.scope.all" | "settings.archive.scope.current" }
const scopeOptions: ScopeOption[] = [
{ value: "all", label: "settings.archive.scope.all" },
{ value: "current", label: "settings.archive.scope.current" },
]
export const SettingsArchive: Component = () => {
const language = useLanguage()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const layout = useLayout()
const params = useParams()
const [removedIds, setRemovedIds] = createSignal<Set<string>>(new Set())
const projects = createMemo(() => globalSync.data.project)
const layoutProjects = createMemo(() => layout.projects.list())
const hasMultipleProjects = createMemo(() => projects().length > 1)
const homedir = createMemo(() => globalSync.data.path.home)
const defaultScope = () => (hasMultipleProjects() ? "current" : "all")
const [filterScope, setFilterScope] = createSignal<FilterScope>(defaultScope())
const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
const currentProject = createMemo(() => {
const dir = currentDirectory()
if (!dir) return null
return layoutProjects().find((p) => p.worktree === dir || p.sandboxes?.includes(dir)) ?? null
})
const filteredProjects = createMemo(() => {
if (filterScope() === "current" && currentProject()) {
return [currentProject()!]
}
return layoutProjects()
})
const getSessionLabel = (session: Session) => {
const directory = session.directory
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
if (filterScope() === "current" && currentProject()) {
const current = currentProject()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = globalSync.child(directory, { bootstrap: false })
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
return path
}
const [archivedSessions] = createResource(
() => ({ scope: filterScope(), projects: filteredProjects() }),
async ({ projects }) => {
const allSessions: Session[] = []
for (const project of projects) {
const directories = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of directories) {
const result = await globalSDK.client.experimental.session.list({ directory, archived: true })
const sessions = result.data ?? []
for (const session of sessions) {
allSessions.push(session)
}
}
}
return allSessions.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
},
{ initialValue: [] },
)
const displayedSessions = () => {
const sessions = archivedSessions() ?? []
const removed = removedIds()
return sessions.filter((s) => !removed.has(s.id))
}
const currentScopeOption = () => scopeOptions.find((o) => o.value === filterScope())
const unarchiveSession = async (session: Session) => {
setRemovedIds((prev) => new Set(prev).add(session.id))
await globalSDK.client.session.update({
directory: session.directory,
sessionID: session.id,
time: { archived: null as any },
})
}
const handleScopeChange = (option: ScopeOption | undefined) => {
if (!option) return
setRemovedIds(new Set<string>())
setFilterScope(option.value)
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.archive.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.archive.description")}</p>
</div>
</div>
<div class="flex flex-col gap-4 max-w-[720px]">
<Show when={hasMultipleProjects()}>
<RadioGroup
options={scopeOptions}
current={currentScopeOption() ?? undefined}
value={(o) => o.value}
size="small"
label={(o) => language.t(o.label)}
onSelect={handleScopeChange}
/>
</Show>
<Show
when={!archivedSessions.loading}
fallback={
<div class="min-h-[700px]">
<SessionSkeleton count={4} />
</div>
}
>
<Show
when={displayedSessions().length}
fallback={
<div class="min-h-[700px]">
<div class="text-14-regular text-text-weak">{language.t("settings.archive.none")}</div>
</div>
}
>
<div class="min-h-[700px] flex flex-col gap-2">
<For each={displayedSessions()}>
{(session) => (
<div class="flex items-center justify-between gap-4 px-3 py-1 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-x-3 grow min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong truncate">{session.title}</span>
<span class="text-14-regular text-text-weak truncate">{getSessionLabel(session)}</span>
</div>
</div>
<div class="flex items-center gap-4 shrink-0">
<Show when={session.time?.updated}>
{(updated) => (
<span class="text-12-regular text-text-weak whitespace-nowrap">
{getRelativeTime(new Date(updated()).toISOString(), language.t)}
</span>
)}
</Show>
<Button
size="normal"
variant="secondary"
onClick={() => unarchiveSession(session)}
>
{language.t("common.unarchive")}
</Button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -265,6 +265,9 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">

View File

@@ -506,6 +506,10 @@ export const dict = {
"common.close": "إغلاق",
"common.edit": "تحرير",
"common.loadMore": "تحميل المزيد",
"common.changelog": "التغييرات",
"common.noReleasesFound": "لم يتم العثور على إصدارات",
"changelog.tag.latest": "الأحدث",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "تبديل القائمة",
"sidebar.nav.projectsAndSessions": "المشاريع والجلسات",
@@ -734,6 +738,11 @@ export const dict = {
"workspace.reset.archived.one": "ستتم أرشفة جلسة واحدة.",
"workspace.reset.archived.many": "ستتم أرشفة {{count}} جلسات.",
"workspace.reset.note": "سيؤدي هذا إلى إعادة تعيين مساحة العمل لتتطابق مع الفرع الافتراضي.",
"settings.archive.title": "الجلسات المؤرشفة",
"settings.archive.description": "استعادة الجلسات المؤرشفة لجعلها مرئية في الشريط الجانبي.",
"settings.archive.none": "لا توجد جلسات مؤرشفة.",
"settings.archive.scope.all": "جميع المشاريع",
"settings.archive.scope.current": "المشروع الحالي",
"common.open": "فتح",
"dialog.releaseNotes.action.getStarted": "البدء",
"dialog.releaseNotes.action.next": "التالي",
@@ -748,4 +757,4 @@ export const dict = {
"common.time.daysAgo.short": "قبل {{count}} ي",
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
}
}

View File

@@ -512,6 +512,9 @@ export const dict = {
"common.close": "Fechar",
"common.edit": "Editar",
"common.loadMore": "Carregar mais",
"common.changelog": "Novidades",
"common.noReleasesFound": "Nenhuma release encontrada",
"changelog.tag.latest": "Mais recente",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Alternar menu",
"sidebar.nav.projectsAndSessions": "Projetos e sessões",
@@ -742,6 +745,11 @@ export const dict = {
"workspace.reset.archived.one": "1 sessão será arquivada.",
"workspace.reset.archived.many": "{{count}} sessões serão arquivadas.",
"workspace.reset.note": "Isso redefinirá o espaço de trabalho para corresponder ao branch padrão.",
"settings.archive.title": "Sessões arquivadas",
"settings.archive.description": "Restaure sessões arquivadas para torná-las visíveis na barra lateral.",
"settings.archive.none": "Nenhuma sessão arquivada.",
"settings.archive.scope.all": "Todos os projetos",
"settings.archive.scope.current": "Projeto atual",
"common.open": "Abrir",
"dialog.releaseNotes.action.getStarted": "Começar",
"dialog.releaseNotes.action.next": "Próximo",

View File

@@ -572,6 +572,9 @@ export const dict = {
"common.close": "Zatvori",
"common.edit": "Uredi",
"common.loadMore": "Učitaj još",
"common.changelog": "Novosti",
"common.noReleasesFound": "Nema pronađenih verzija",
"changelog.tag.latest": "Najnovije",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Prikaži/sakrij meni",
@@ -819,6 +822,11 @@ export const dict = {
"workspace.reset.archived.one": "1 sesija će biti arhivirana.",
"workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.",
"workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.",
"settings.archive.title": "Arhivirane sesije",
"settings.archive.description": "Vrati arhivirane sesije da bi bile vidljive u bočnoj traci.",
"settings.archive.none": "Nema arhiviranih sesija.",
"settings.archive.scope.all": "Svi projekti",
"settings.archive.scope.current": "Trenutni projekt",
"common.open": "Otvori",
"dialog.releaseNotes.action.getStarted": "Započni",
"dialog.releaseNotes.action.next": "Sljedeće",

View File

@@ -568,6 +568,9 @@ export const dict = {
"common.close": "Luk",
"common.edit": "Rediger",
"common.loadMore": "Indlæs flere",
"common.changelog": "Nyheder",
"common.noReleasesFound": "Ingen versioner fundet",
"changelog.tag.latest": "Seneste",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Skift menu",
@@ -813,6 +816,11 @@ export const dict = {
"workspace.reset.archived.one": "1 session vil blive arkiveret.",
"workspace.reset.archived.many": "{{count}} sessioner vil blive arkiveret.",
"workspace.reset.note": "Dette vil nulstille arbejdsområdet til at matche hovedgrenen.",
"settings.archive.title": "Arkiverede sessioner",
"settings.archive.description": "Gendan arkiverede sessioner for at gøre dem synlige i sidebjælken.",
"settings.archive.none": "Ingen arkiverede sessioner.",
"settings.archive.scope.all": "Alle projekter",
"settings.archive.scope.current": "Nuværende projekt",
"common.open": "Åbn",
"dialog.releaseNotes.action.getStarted": "Kom i gang",
"dialog.releaseNotes.action.next": "Næste",

View File

@@ -520,6 +520,10 @@ export const dict = {
"common.close": "Schließen",
"common.edit": "Bearbeiten",
"common.loadMore": "Mehr laden",
"common.changelog": "Neuerungen",
"common.noReleasesFound": "Keine Versionen gefunden",
"changelog.tag.latest": "Neueste",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Menü umschalten",
"sidebar.nav.projectsAndSessions": "Projekte und Sitzungen",
@@ -751,6 +755,12 @@ export const dict = {
"workspace.reset.archived.one": "1 Sitzung wird archiviert.",
"workspace.reset.archived.many": "{{count}} Sitzungen werden archiviert.",
"workspace.reset.note": "Dadurch wird der Arbeitsbereich auf den Standard-Branch zurückgesetzt.",
"settings.archive.title": "Archivierte Sitzungen",
"settings.archive.description": "Archivierte Sitzungen wiederherstellen, um sie in der Seitenleiste anzuzeigen.",
"settings.archive.none": "Keine archivierten Sitzungen.",
"settings.archive.scope.all": "Alle Projekte",
"settings.archive.scope.current": "Aktuelles Projekt",
"common.open": "Öffnen",
"dialog.releaseNotes.action.getStarted": "Loslegen",
"dialog.releaseNotes.action.next": "Weiter",

View File

@@ -585,16 +585,19 @@ export const dict = {
"common.rename": "Rename",
"common.reset": "Reset",
"common.archive": "Archive",
"common.unarchive": "Unarchive",
"common.delete": "Delete",
"common.close": "Close",
"common.edit": "Edit",
"common.loadMore": "Load more",
"common.key.esc": "ESC",
"common.changelog": "Changelog",
"common.noReleasesFound": "No releases found",
"common.time.justNow": "Just now",
"common.time.minutesAgo.short": "{{count}}m ago",
"common.time.hoursAgo.short": "{{count}}h ago",
"common.time.daysAgo.short": "{{count}}d ago",
"changelog.tag.latest": "Latest",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Toggle menu",
"sidebar.nav.projectsAndSessions": "Projects and sessions",
@@ -613,6 +616,7 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.section.data": "Data",
"settings.tab.general": "General",
"settings.tab.shortcuts": "Shortcuts",
"settings.desktop.section.wsl": "WSL",
@@ -844,4 +848,10 @@ export const dict = {
"workspace.reset.archived.one": "1 session will be archived.",
"workspace.reset.archived.many": "{{count}} sessions will be archived.",
"workspace.reset.note": "This will reset the workspace to match the default branch.",
"settings.archive.title": "Archived Sessions",
"settings.archive.description": "Restore archived sessions to make them visible in the sidebar.",
"settings.archive.none": "No archived sessions.",
"settings.archive.scope.all": "All projects",
"settings.archive.scope.current": "Current project",
}

View File

@@ -575,6 +575,10 @@ export const dict = {
"common.close": "Cerrar",
"common.edit": "Editar",
"common.loadMore": "Cargar más",
"common.changelog": "Novedades",
"common.noReleasesFound": "No se encontraron versiones",
"changelog.tag.latest": "Último",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Alternar menú",
@@ -825,6 +829,12 @@ export const dict = {
"workspace.reset.archived.one": "1 sesión será archivada.",
"workspace.reset.archived.many": "{{count}} sesiones serán archivadas.",
"workspace.reset.note": "Esto restablecerá el espacio de trabajo para coincidir con la rama predeterminada.",
"settings.archive.title": "Sesiones archivadas",
"settings.archive.description": "Restaura las sesiones archivadas para hacerlas visibles en la barra lateral.",
"settings.archive.none": "No hay sesiones archivadas.",
"settings.archive.scope.all": "Todos los proyectos",
"settings.archive.scope.current": "Proyecto actual",
"common.open": "Abrir",
"dialog.releaseNotes.action.getStarted": "Comenzar",
"dialog.releaseNotes.action.next": "Siguiente",

View File

@@ -516,6 +516,10 @@ export const dict = {
"common.close": "Fermer",
"common.edit": "Modifier",
"common.loadMore": "Charger plus",
"common.changelog": "Nouveautés",
"common.noReleasesFound": "Aucune version trouvée",
"changelog.tag.latest": "Dernier",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Basculer le menu",
"sidebar.nav.projectsAndSessions": "Projets et sessions",
@@ -749,6 +753,11 @@ export const dict = {
"workspace.reset.archived.one": "1 session sera archivée.",
"workspace.reset.archived.many": "{{count}} sessions seront archivées.",
"workspace.reset.note": "Cela réinitialisera l'espace de travail pour correspondre à la branche par défaut.",
"settings.archive.title": "Sessions archivées",
"settings.archive.description": "Restaurez les sessions archivées pour les rendre visibles dans la barre latérale.",
"settings.archive.none": "Aucune session archivée.",
"settings.archive.scope.all": "Tous les Projets",
"settings.archive.scope.current": "Projet actuel",
"common.open": "Ouvrir",
"dialog.releaseNotes.action.getStarted": "Commencer",
"dialog.releaseNotes.action.next": "Suivant",

View File

@@ -510,6 +510,10 @@ export const dict = {
"common.close": "閉じる",
"common.edit": "編集",
"common.loadMore": "さらに読み込む",
"common.changelog": "更新履歴",
"common.noReleasesFound": "バージョンが見つかりません",
"changelog.tag.latest": "最新",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "メニューを切り替え",
"sidebar.nav.projectsAndSessions": "プロジェクトとセッション",
@@ -738,6 +742,12 @@ export const dict = {
"workspace.reset.archived.one": "1つのセッションがアーカイブされます。",
"workspace.reset.archived.many": "{{count}}個のセッションがアーカイブされます。",
"workspace.reset.note": "これにより、ワークスペースはデフォルトブランチと一致するようにリセットされます。",
"settings.archive.title": "アーカイブされたセッション",
"settings.archive.description": "アーカイブされたセッションを復元してサイドバーに表示します。",
"settings.archive.none": "アーカイブされたセッションはありません。",
"settings.archive.scope.all": "すべてのプロジェクト",
"settings.archive.scope.current": "現在のプロジェクト",
"common.open": "開く",
"dialog.releaseNotes.action.getStarted": "始める",
"dialog.releaseNotes.action.next": "次へ",

View File

@@ -511,6 +511,10 @@ export const dict = {
"common.close": "닫기",
"common.edit": "편집",
"common.loadMore": "더 불러오기",
"common.changelog": "새로운 기능",
"common.noReleasesFound": "버전을 찾을 수 없음",
"changelog.tag.latest": "최신",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "메뉴 토글",
"sidebar.nav.projectsAndSessions": "프로젝트 및 세션",
@@ -738,6 +742,12 @@ export const dict = {
"workspace.reset.archived.one": "1개의 세션이 보관됩니다.",
"workspace.reset.archived.many": "{{count}}개의 세션이 보관됩니다.",
"workspace.reset.note": "이 작업은 작업 공간을 기본 브랜치와 일치하도록 재설정합니다.",
"settings.archive.title": "보관된 세션",
"settings.archive.description": "보관된 세션을 복원하여 사이드바에 표시합니다.",
"settings.archive.none": "보관된 세션이 없습니다.",
"settings.archive.scope.all": "모든 프로젝트",
"settings.archive.scope.current": "현재 프로젝트",
"common.open": "열기",
"dialog.releaseNotes.action.getStarted": "시작하기",
"dialog.releaseNotes.action.next": "다음",

View File

@@ -575,6 +575,9 @@ export const dict = {
"common.close": "Lukk",
"common.edit": "Rediger",
"common.loadMore": "Last flere",
"common.changelog": "Nyheter",
"common.noReleasesFound": "Ingen versjoner funnet",
"changelog.tag.latest": "Siste",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Veksle meny",
@@ -821,6 +824,12 @@ export const dict = {
"workspace.reset.archived.one": "1 sesjon vil bli arkivert.",
"workspace.reset.archived.many": "{{count}} sesjoner vil bli arkivert.",
"workspace.reset.note": "Dette vil tilbakestille arbeidsområdet til å samsvare med standardgrenen.",
"settings.archive.title": "Arkiverte økter",
"settings.archive.description": "Gjenopprett arkiverte økter for å gjøre dem synlige i sidefeltet.",
"settings.archive.none": "Ingen arkiverte økter.",
"settings.archive.scope.all": "Alle prosjekter",
"settings.archive.scope.current": "Nåværende prosjekt",
"common.open": "Åpne",
"dialog.releaseNotes.action.getStarted": "Kom i gang",
"dialog.releaseNotes.action.next": "Neste",

View File

@@ -511,6 +511,9 @@ export const dict = {
"common.close": "Zamknij",
"common.edit": "Edytuj",
"common.loadMore": "Załaduj więcej",
"common.changelog": "Nowości",
"common.noReleasesFound": "Nie znaleziono wersji",
"changelog.tag.latest": "Najnowszy",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Przełącz menu",
"sidebar.nav.projectsAndSessions": "Projekty i sesje",
@@ -740,6 +743,11 @@ export const dict = {
"workspace.reset.archived.one": "1 sesja zostanie zarchiwizowana.",
"workspace.reset.archived.many": "{{count}} sesji zostanie zarchiwizowanych.",
"workspace.reset.note": "To zresetuje przestrzeń roboczą, aby odpowiadała domyślnej gałęzi.",
"settings.archive.title": "Zarchiwizowane sesje",
"settings.archive.description": "Przywróć zarchiwizowane sesje, aby były widoczne na pasku bocznym.",
"settings.archive.none": "Brak zarchiwizowanych sesji.",
"settings.archive.scope.all": "Wszystkie projekty",
"settings.archive.scope.current": "Bieżący projekt",
"common.open": "Otwórz",
"dialog.releaseNotes.action.getStarted": "Rozpocznij",
"dialog.releaseNotes.action.next": "Dalej",

View File

@@ -573,6 +573,9 @@ export const dict = {
"common.close": "Закрыть",
"common.edit": "Редактировать",
"common.loadMore": "Загрузить ещё",
"common.changelog": "Что нового",
"common.noReleasesFound": "Версии не найдены",
"changelog.tag.latest": "Последний",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Переключить меню",
@@ -821,6 +824,11 @@ export const dict = {
"workspace.reset.archived.one": "1 сессия будет архивирована.",
"workspace.reset.archived.many": "{{count}} сессий будет архивировано.",
"workspace.reset.note": "Рабочее пространство будет сброшено в соответствие с веткой по умолчанию.",
"settings.archive.title": "Архивированные сессии",
"settings.archive.description": "Восстановите архивированные сессии, чтобы они отображались на боковой панели.",
"settings.archive.none": "Нет архивированных сессий.",
"settings.archive.scope.all": "Все проекты",
"settings.archive.scope.current": "Текущий проект",
"common.open": "Открыть",
"dialog.releaseNotes.action.getStarted": "Начать",
"dialog.releaseNotes.action.next": "Далее",

View File

@@ -567,6 +567,9 @@ export const dict = {
"common.close": "ปิด",
"common.edit": "แก้ไข",
"common.loadMore": "โหลดเพิ่มเติม",
"common.changelog": "อัปเดต",
"common.noReleasesFound": "ไม่พบเวอร์ชัน",
"changelog.tag.latest": "ล่าสุด",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "สลับเมนู",
@@ -811,6 +814,12 @@ export const dict = {
"workspace.reset.archived.one": "1 เซสชันจะถูกจัดเก็บ",
"workspace.reset.archived.many": "{{count}} เซสชันจะถูกจัดเก็บ",
"workspace.reset.note": "สิ่งนี้จะรีเซ็ตพื้นที่ทำงานให้ตรงกับสาขาเริ่มต้น",
"settings.archive.title": "เซสชันที่จัดเก็บ",
"settings.archive.description": "กู้คืนเซสชันที่จัดเก็บเพื่อให้แสดงในแถบด้านข้าง",
"settings.archive.none": "ไม่มีเซสชันที่จัดเก็บ",
"settings.archive.scope.all": "โปรเจกต์ทั้งหมด",
"settings.archive.scope.current": "โปรเจกต์ปัจจุบัน",
"common.open": "เปิด",
"dialog.releaseNotes.action.getStarted": "เริ่มต้น",
"dialog.releaseNotes.action.next": "ถัดไป",

View File

@@ -566,6 +566,10 @@ export const dict = {
"common.close": "关闭",
"common.edit": "编辑",
"common.loadMore": "加载更多",
"common.changelog": "更新日志",
"common.noReleasesFound": "未找到版本",
"changelog.tag.latest": "最新",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "切换菜单",
@@ -809,6 +813,12 @@ export const dict = {
"workspace.reset.archived.one": "将归档 1 个会话。",
"workspace.reset.archived.many": "将归档 {{count}} 个会话。",
"workspace.reset.note": "这将把工作区重置为与默认分支一致。",
"settings.archive.title": "归档会话",
"settings.archive.description": "恢复归档会话以使其在侧边栏中可见。",
"settings.archive.none": "没有归档会话。",
"settings.archive.scope.all": "所有项目",
"settings.archive.scope.current": "当前项目",
"common.open": "打开",
"dialog.releaseNotes.action.getStarted": "开始",
"dialog.releaseNotes.action.next": "下一步",

View File

@@ -563,6 +563,9 @@ export const dict = {
"common.close": "關閉",
"common.edit": "編輯",
"common.loadMore": "載入更多",
"common.changelog": "更新日誌",
"common.noReleasesFound": "未找到版本",
"changelog.tag.latest": "最新",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "切換選單",
@@ -804,6 +807,12 @@ export const dict = {
"workspace.reset.archived.one": "將封存 1 個工作階段。",
"workspace.reset.archived.many": "將封存 {{count}} 個工作階段。",
"workspace.reset.note": "這將把工作區重設為與預設分支一致。",
"settings.archive.title": "封存工作階段",
"settings.archive.description": "恢復封存的工作階段以使其在側邊欄中可見。",
"settings.archive.none": "沒有封存的工作階段。",
"settings.archive.scope.all": "所有專案",
"settings.archive.scope.current": "目前專案",
"common.open": "打開",
"dialog.releaseNotes.action.getStarted": "開始",
"dialog.releaseNotes.action.next": "下一步",

View File

@@ -43,6 +43,7 @@ import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { createAim } from "@/utils/aim"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -66,7 +67,12 @@ import {
syncWorkspaceOrder,
workspaceKey,
} from "./layout/helpers"
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
deepLinkEvent,
drainPendingDeepLinks,
} from "./layout/deep-links"
import { createInlineEditorController } from "./layout/inline-editor"
import {
LocalWorkspace,
@@ -1157,9 +1163,20 @@ export default function Layout(props: ParentProps) {
const handleDeepLinks = (urls: string[]) => {
if (!server.isLocal()) return
for (const directory of collectOpenProjectDeepLinks(urls)) {
openProject(directory)
}
for (const link of collectNewSessionDeepLinks(urls)) {
openProject(link.directory, false)
const slug = base64Encode(link.directory)
if (link.prompt) {
setSessionHandoff(slug, { prompt: link.prompt })
}
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
navigateWithSidebarReset(href)
}
}
onMount(() => {

View File

@@ -1,15 +1,17 @@
export const deepLinkEvent = "opencode:deep-link"
export const parseDeepLink = (input: string) => {
const parseUrl = (input: string) => {
if (!input.startsWith("opencode://")) return
if (typeof URL.canParse === "function" && !URL.canParse(input)) return
const url = (() => {
try {
return new URL(input)
} catch {
return undefined
}
})()
try {
return new URL(input)
} catch {
return
}
}
export const parseDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return
if (url.hostname !== "open-project") return
const directory = url.searchParams.get("directory")
@@ -17,9 +19,23 @@ export const parseDeepLink = (input: string) => {
return directory
}
export const parseNewSessionDeepLink = (input: string) => {
const url = parseUrl(input)
if (!url) return
if (url.hostname !== "new-session") return
const directory = url.searchParams.get("directory")
if (!directory) return
const prompt = url.searchParams.get("prompt") || undefined
if (!prompt) return { directory }
return { directory, prompt }
}
export const collectOpenProjectDeepLinks = (urls: string[]) =>
urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
export const collectNewSessionDeepLinks = (urls: string[]) =>
urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link)
type OpenCodeWindow = Window & {
__OPENCODE__?: {
deepLinks?: string[]

View File

@@ -1,15 +1,14 @@
import { describe, expect, test } from "bun:test"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
import {
displayName,
errorMessage,
getDraggableId,
hasProjectPermissions,
latestRootSession,
syncWorkspaceOrder,
workspaceKey,
} from "./helpers"
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
drainPendingDeepLinks,
parseDeepLink,
parseNewSessionDeepLink,
} from "./deep-links"
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { hasProjectPermissions, latestRootSession } from "./helpers"
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({
@@ -62,6 +61,28 @@ describe("layout deep links", () => {
expect(result).toEqual(["/a", "/c"])
})
test("parses new-session deep links with optional prompt", () => {
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" })
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({
directory: "/tmp/demo",
prompt: "hello world",
})
})
test("ignores new-session deep links without directory", () => {
expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined()
expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined()
})
test("collects only valid new-session deep links", () => {
const result = collectNewSessionDeepLinks([
"opencode://new-session?directory=/a",
"opencode://open-project?directory=/b",
"opencode://new-session?directory=/c&prompt=ship%20it",
])
expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }])
})
test("drains global deep links once", () => {
const target = {
__OPENCODE__: {

View File

@@ -1,36 +1,35 @@
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount, untrack } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { base64Encode, checksum } from "@opencode-ai/util/encode"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, Match, on, onCleanup, onMount, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { SessionHeader, NewSessionView } from "@/components/session"
import { same } from "@/utils/same"
import { type FileSelection, type SelectedLineRange, selectionFromLines, useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { same } from "@/utils/same"
const emptyUserMessages: UserMessage[] = []
@@ -253,6 +252,19 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
createEffect(() => {
if (!untrack(() => prompt.ready())) return
prompt.ready()
untrack(() => {
if (params.id || !prompt.ready()) return
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
})
const [ui, setUi] = createStore({
pendingMessage: undefined as string | undefined,
@@ -673,7 +685,11 @@ export default function Page() {
on(
sessionKey,
() => {
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
setTree({
reviewScroll: undefined,
pendingDiff: undefined,
activeDiff: undefined,
})
},
{ defer: true },
),

View File

@@ -1,5 +1,6 @@
import { Show, createEffect, createMemo } from "solid-js"
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
@@ -18,6 +19,23 @@ export function SessionComposerRegion(props: {
onSubmit: () => void
onResponseSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
visualDuration?: number
bounce?: number
dockOpenVisualDuration?: number
dockOpenBounce?: number
dockCloseVisualDuration?: number
dockCloseBounce?: number
drawerExpandVisualDuration?: number
drawerExpandBounce?: number
drawerCollapseVisualDuration?: number
drawerCollapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) {
const params = useParams()
const prompt = usePrompt()
@@ -43,6 +61,40 @@ export function SessionComposerRegion(props: {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const open = createMemo(() => props.state.dock() && !props.state.closing())
const config = createMemo(() =>
open()
? {
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
},
)
const progress = useSpring(
() => (open() ? 1 : 0),
config,
)
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const [height, setHeight] = createSignal(320)
const dock = createMemo(() => props.state.dock() || value() > 0.001)
const full = createMemo(() => Math.max(78, height()))
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
createEffect(() => {
const el = contentRef()
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return (
<div
ref={props.setPromptDockRef}
@@ -87,30 +139,46 @@ export function SessionComposerRegion(props: {
</div>
}
>
<Show when={props.state.dock()}>
<Show when={dock()}>
<div
classList={{
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"max-h-[320px]": !props.state.closing(),
"max-h-0 pointer-events-none": props.state.closing(),
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
"overflow-hidden": true,
"pointer-events-none": value() < 0.98,
}}
style={{
"max-height": `${full() * value()}px`,
}}
>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
/>
<div ref={setContentRef}>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={value()}
visualDuration={props.visualDuration}
bounce={props.bounce}
expandVisualDuration={props.drawerExpandVisualDuration}
expandBounce={props.drawerExpandBounce}
collapseVisualDuration={props.drawerCollapseVisualDuration}
collapseBounce={props.drawerCollapseBounce}
subtitleDuration={props.subtitleDuration}
subtitleTravel={props.subtitleTravel}
subtitleEdge={props.subtitleEdge}
countDuration={props.countDuration}
countMask={props.countMask}
countMaskHeight={props.countMaskHeight}
countWidthDuration={props.countWidthDuration}
/>
</div>
</div>
</Show>
<div
classList={{
"relative z-10": true,
"transition-[margin] duration-[400ms] ease-out": true,
"-mt-9": props.state.dock() && !props.state.closing(),
"mt-0": !props.state.dock() || props.state.closing(),
}}
style={{
"margin-top": `${-36 * value()}px`,
}}
>
<PromptInput

View File

@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
})
}
export function createSessionComposerState() {
export function createSessionComposerState(
options?: {
closeMs?: number | (() => number)
},
) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
@@ -96,12 +100,19 @@ export function createSessionComposerState() {
let timer: number | undefined
let raf: number | undefined
const closeMs = () => {
const value = options?.closeMs
if (typeof value === "function") return Math.max(0, value())
if (typeof value === "number") return Math.max(0, value)
return 400
}
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore({ dock: false, closing: false })
timer = undefined
}, 400)
}, closeMs())
}
createEffect(

View File

@@ -1,8 +1,12 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
function dot(status: Todo["status"]) {
@@ -30,19 +34,35 @@ function dot(status: Todo["status"]) {
)
}
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
export function SessionTodoDock(props: {
todos: Todo[]
title: string
collapseLabel: string
expandLabel: string
dockProgress?: number
visualDuration?: number
bounce?: number
expandVisualDuration?: number
expandBounce?: number
collapseVisualDuration?: number
collapseBounce?: number
subtitleDuration?: number
subtitleTravel?: number
subtitleEdge?: number
countDuration?: number
countMask?: number
countMaskHeight?: number
countWidthDuration?: number
}) {
const [store, setStore] = createStore({
collapsed: false,
})
const toggle = () => setStore("collapsed", (value) => !value)
const summary = createMemo(() => {
const total = props.todos.length
if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
})
const total = createMemo(() => props.todos.length)
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`)
const active = createMemo(
() =>
@@ -53,56 +73,135 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
)
const preview = createMemo(() => active()?.content ?? "")
const config = createMemo(() =>
store.collapsed
? {
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.collapseBounce ?? props.bounce ?? 0,
}
: {
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
bounce: props.expandBounce ?? props.bounce ?? 0,
},
)
const collapse = useSpring(
() => (store.collapsed ? 1 : 0),
config,
)
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
const shut = createMemo(() => 1 - dock())
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const hide = createMemo(() => Math.max(value(), shut()))
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
const [height, setHeight] = createSignal(320)
const full = createMemo(() => Math.max(78, height()))
let contentRef: HTMLDivElement | undefined
createEffect(() => {
const el = contentRef
if (!el) return
const update = () => {
setHeight(el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return (
<DockTray
data-component="session-todo-dock"
classList={{
"h-[78px]": store.collapsed,
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
}}
>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
<Show when={store.collapsed}>
<div class="ml-1 flex-1 min-w-0">
<Show when={preview()}>
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
</Show>
<div ref={contentRef}>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
aria-label={label()}
style={{
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
"--tool-motion-mask": `${props.countMask ?? 18}%`,
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
}}
>
<AnimatedNumber value={done()} />
<span class="mx-1">of</span>
<AnimatedNumber value={total()} />
<span>&nbsp;{props.title.toLowerCase()} completed</span>
</span>
<div
data-slot="session-todo-preview"
class="ml-1 min-w-0 overflow-hidden"
style={{
flex: "1 1 auto",
"max-width": "100%",
}}
>
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={store.collapsed ? preview() : undefined}
duration={props.subtitleDuration ?? 600}
travel={props.subtitleTravel ?? 25}
edge={props.subtitleEdge ?? 17}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
truncate
/>
</div>
<div class="ml-auto">
<IconButton
data-action="session-todo-toggle-button"
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${turn() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</Show>
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
<IconButton
data-action="session-todo-toggle-button"
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<div data-slot="session-todo-list" hidden={store.collapsed}>
<TodoList todos={props.todos} open={!store.collapsed} />
<div
data-slot="session-todo-list"
aria-hidden={store.collapsed}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
}}
>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</div>
</DockTray>
)
@@ -171,33 +270,43 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
}, 250)
}}
>
<For each={props.todos}>
<Index each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo.status === "completed"}
indeterminate={todo.status === "in_progress"}
data-in-progress={todo.status === "in_progress" ? "" : undefined}
icon={dot(todo.status)}
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
checked={todo().status === "completed"}
indeterminate={todo().status === "in_progress"}
data-in-progress={todo().status === "in_progress" ? "" : undefined}
data-state={todo().status}
icon={dot(todo().status)}
style={{
"--checkbox-align": "flex-start",
"--checkbox-offset": "1px",
transition:
"opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
opacity: todo().status === "pending" ? "0.94" : "1",
filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
}}
>
<span
<TextStrikethrough
active={todo().status === "completed" || todo().status === "cancelled"}
text={todo().content}
class="text-14-regular min-w-0 break-words"
classList={{
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
}}
style={{
"line-height": "var(--line-height-normal)",
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
transition:
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
opacity: todo().status === "pending" ? "0.92" : "1",
filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
}}
>
{todo.content}
</span>
/>
</Checkbox>
)}
</For>
</Index>
</div>
<div
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"

View File

@@ -213,6 +213,7 @@ export function MessageTimeline(props: {
const dialog = useDialog()
const language = useLanguage()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const info = createMemo(() => {
@@ -651,17 +652,17 @@ export function MessageTimeline(props: {
</Button>
</div>
</Show>
<For each={staging.messages()}>
{(message) => {
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
<For each={rendered()}>
{(messageID) => {
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []))
const commentCount = createMemo(() => comments().length)
return (
<div
id={props.anchor(message.id)}
data-message-id={message.id}
id={props.anchor(messageID)}
data-message-id={messageID}
ref={(el) => {
props.onRegisterMessage(el, message.id)
onCleanup(() => props.onUnregisterMessage(message.id))
props.onRegisterMessage(el, messageID)
onCleanup(() => props.onUnregisterMessage(messageID))
}}
classList={{
"min-w-0 w-full max-w-full": true,
@@ -701,7 +702,7 @@ export function MessageTimeline(props: {
</Show>
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={message.id}
messageID={messageID}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}

View File

@@ -19,4 +19,4 @@ export function getRelativeTime(dateString: string, t: Translate): string {
if (diffMinutes < 60) return t("common.time.minutesAgo.short", { count: diffMinutes })
if (diffHours < 24) return t("common.time.hoursAgo.short", { count: diffHours })
return t("common.time.daysAgo.short", { count: diffDays })
}
}

View File

@@ -43,6 +43,7 @@
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
@@ -89,8 +90,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.81",
"@opentui/solid": "0.1.81",
"@opentui/core": "0.1.84",
"@opentui/solid": "0.1.84",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -127,6 +128,7 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",

View File

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

View File

@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -38,7 +39,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await Bun.sleep(10)
await sleep(10)
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {

View File

@@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { EOL } from "os"
import { setTimeout as sleep } from "node:timers/promises"
export const LSPCommand = cmd({
command: "lsp",
@@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
await Bun.sleep(1000)
await sleep(1000)
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
})
},

View File

@@ -28,6 +28,7 @@ import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -353,7 +354,7 @@ export const GithubInstallCommand = cmd({
}
retries++
await Bun.sleep(1000)
await sleep(1000)
} while (true)
s.stop("Installed GitHub app")
@@ -1372,7 +1373,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
} catch (e) {
if (retries > 0) {
console.log(`Retrying after ${delayMs}ms...`)
await Bun.sleep(delayMs)
await sleep(delayMs)
return withRetry(fn, retries - 1, delayMs)
}
throw e

View File

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

View File

@@ -9,6 +9,7 @@ import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
@@ -17,7 +18,7 @@ function pagerCmd(): string[] {
}
// user could have less installed via other options
const lessOnPath = Bun.which("less")
const lessOnPath = which("less")
if (lessOnPath) {
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
}
@@ -27,7 +28,7 @@ function pagerCmd(): string[] {
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
}
const git = Bun.which("git")
const git = which("git")
if (git) {
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]

View File

@@ -462,6 +462,7 @@ function App() {
{
title: "Toggle MCPs",
value: "mcp.list",
search: "toggle mcps",
category: "Agent",
slash: {
name: "mcps",
@@ -537,8 +538,9 @@ function App() {
category: "System",
},
{
title: "Toggle appearance",
title: mode() === "dark" ? "Light mode" : "Dark mode",
value: "theme.switch_mode",
search: "toggle appearance",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -577,6 +579,7 @@ function App() {
},
{
title: "Toggle debug panel",
search: "toggle debug",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
@@ -586,6 +589,7 @@ function App() {
},
{
title: "Toggle console",
search: "toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
@@ -626,6 +630,7 @@ function App() {
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
search: "toggle terminal title",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
@@ -641,6 +646,7 @@ function App() {
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
search: "toggle animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
@@ -650,6 +656,7 @@ function App() {
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
search: "toggle diff wrapping",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { useKV } from "./kv"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
@@ -103,6 +104,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
sdk.event.listen((e) => {
const event = e.details
@@ -127,6 +130,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
@@ -441,6 +451,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)

View File

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

View File

@@ -154,7 +154,7 @@ export function Session() {
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
const [showHeader, setShowHeader] = kv.signal("header_visible", true)
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
@@ -553,6 +553,7 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
search: "toggle sidebar",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -567,6 +568,7 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
search: "toggle code concealment",
keybind: "messages_toggle_conceal" as any,
category: "Session",
onSelect: (dialog) => {
@@ -577,6 +579,7 @@ export function Session() {
{
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
search: "toggle timestamps",
category: "Session",
slash: {
name: "timestamps",
@@ -590,6 +593,7 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
search: "toggle thinking",
keybind: "display_thinking",
category: "Session",
slash: {
@@ -604,6 +608,7 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
search: "toggle tool details",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
@@ -612,8 +617,9 @@ export function Session() {
},
},
{
title: "Toggle session scrollbar",
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
value: "session.toggle.scrollbar",
search: "toggle session scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -1447,6 +1453,10 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
streaming={true}
content={props.part.text.trim()}
conceal={ctx.conceal()}
tableOptions={{
widthMode: "full",
columnFitter: "balanced",
}}
/>
</Match>
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
@@ -2049,7 +2059,9 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
Edit{" "}
{normalizePath(props.input.filePath!)}{" "}
{input({ replaceAll: "replaceAll" in props.input ? props.input.replaceAll : undefined })}
</InlineTool>
</Match>
</Switch>

View File

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

View File

@@ -6,6 +6,7 @@ import { tmpdir } from "os"
import path from "path"
import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
import { which } from "../../../../util/which"
/**
* Writes text to clipboard via OSC 52 escape sequence.
@@ -76,7 +77,7 @@ export namespace Clipboard {
const getCopyMethod = lazy(() => {
const os = platform()
if (os === "darwin" && Bun.which("osascript")) {
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
@@ -85,7 +86,7 @@ export namespace Clipboard {
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
@@ -95,7 +96,7 @@ export namespace Clipboard {
await proc.exited.catch(() => {})
}
}
if (Bun.which("xclip")) {
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
@@ -109,7 +110,7 @@ export namespace Clipboard {
await proc.exited.catch(() => {})
}
}
if (Bun.which("xsel")) {
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {

View File

@@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -75,7 +76,7 @@ const startEventStream = (directory: string) => {
).catch(() => undefined)
if (!events) {
await Bun.sleep(250)
await sleep(250)
continue
}
@@ -84,7 +85,7 @@ const startEventStream = (directory: string) => {
}
if (!signal.aborted) {
await Bun.sleep(250)
await sleep(250)
}
}
})().catch((error) => {

View File

@@ -776,6 +776,7 @@ export namespace Config {
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
@@ -816,7 +817,12 @@ export namespace Config {
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
permission_auto_accept_toggle: z
.string()
.optional()
.default("shift+tab")
.describe("Toggle auto-accept mode for permissions"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
@@ -1155,6 +1161,16 @@ export namespace Config {
.object({
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
hashline_edit: z
.boolean()
.optional()
.describe("Enable hashline-backed edit/read tool behavior (default true, set false to disable)"),
hashline_autocorrect: z
.boolean()
.optional()
.describe(
"Enable hashline autocorrect cleanup for copied prefixes and formatting artifacts (default true)",
),
openTelemetry: z
.boolean()
.optional()

View File

@@ -8,6 +8,7 @@ import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { text } from "node:stream/consumers"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
@@ -126,7 +127,7 @@ export namespace Ripgrep {
)
const state = lazy(async () => {
const system = Bun.which("rg")
const system = which("rg")
if (system) {
const stat = await fs.stat(system).catch(() => undefined)
if (stat?.isFile()) return { filepath: system }

View File

@@ -3,6 +3,11 @@ function truthy(key: string) {
return value === "true" || value === "1"
}
function falsy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "false" || value === "0"
}
export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
@@ -52,7 +57,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]

View File

@@ -3,6 +3,7 @@ import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
export interface Info {
@@ -18,7 +19,7 @@ export const gofmt: Info = {
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return Bun.which("gofmt") !== null
return which("gofmt") !== null
},
}
@@ -27,7 +28,7 @@ export const mix: Info = {
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return Bun.which("mix") !== null
return which("mix") !== null
},
}
@@ -152,7 +153,7 @@ export const zig: Info = {
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return Bun.which("zig") !== null
return which("zig") !== null
},
}
@@ -171,7 +172,7 @@ export const ktlint: Info = {
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return Bun.which("ktlint") !== null
return which("ktlint") !== null
},
}
@@ -180,7 +181,7 @@ export const ruff: Info = {
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (!Bun.which("ruff")) return false
if (!which("ruff")) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
@@ -210,7 +211,7 @@ export const rlang: Info = {
command: ["air", "format", "$FILE"],
extensions: [".R"],
async enabled() {
const airPath = Bun.which("air")
const airPath = which("air")
if (airPath == null) return false
try {
@@ -239,7 +240,7 @@ export const uvformat: Info = {
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
if (Bun.which("uv") !== null) {
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
@@ -253,7 +254,7 @@ export const rubocop: Info = {
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("rubocop") !== null
return which("rubocop") !== null
},
}
@@ -262,7 +263,7 @@ export const standardrb: Info = {
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("standardrb") !== null
return which("standardrb") !== null
},
}
@@ -271,7 +272,7 @@ export const htmlbeautifier: Info = {
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return Bun.which("htmlbeautifier") !== null
return which("htmlbeautifier") !== null
},
}
@@ -280,7 +281,7 @@ export const dart: Info = {
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
return Bun.which("dart") !== null
return which("dart") !== null
},
}
@@ -289,7 +290,7 @@ export const ocamlformat: Info = {
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"],
async enabled() {
if (!Bun.which("ocamlformat")) return false
if (!which("ocamlformat")) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0
},
@@ -300,7 +301,7 @@ export const terraform: Info = {
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"],
async enabled() {
return Bun.which("terraform") !== null
return which("terraform") !== null
},
}
@@ -309,7 +310,7 @@ export const latexindent: Info = {
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"],
async enabled() {
return Bun.which("latexindent") !== null
return which("latexindent") !== null
},
}
@@ -318,7 +319,7 @@ export const gleam: Info = {
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"],
async enabled() {
return Bun.which("gleam") !== null
return which("gleam") !== null
},
}
@@ -327,7 +328,7 @@ export const shfmt: Info = {
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
return Bun.which("shfmt") !== null
return which("shfmt") !== null
},
}
@@ -336,7 +337,7 @@ export const nixfmt: Info = {
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
return Bun.which("nixfmt") !== null
return which("nixfmt") !== null
},
}
@@ -345,7 +346,7 @@ export const rustfmt: Info = {
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return Bun.which("rustfmt") !== null
return which("rustfmt") !== null
},
}
@@ -372,7 +373,7 @@ export const ormolu: Info = {
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return Bun.which("ormolu") !== null
return which("ormolu") !== null
},
}
@@ -381,7 +382,7 @@ export const cljfmt: Info = {
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() {
return Bun.which("cljfmt") !== null
return which("cljfmt") !== null
},
}
@@ -390,6 +391,6 @@ export const dfmt: Info = {
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"],
async enabled() {
return Bun.which("dfmt") !== null
return which("dfmt") !== null
},
}

View File

@@ -12,6 +12,7 @@ import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util/archive"
import { Process } from "../util/process"
import { which } from "../util/which"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -75,7 +76,7 @@ export namespace LSPServer {
},
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
async spawn(root) {
const deno = Bun.which("deno")
const deno = which("deno")
if (!deno) {
log.info("deno not found, please install deno first")
return
@@ -122,7 +123,7 @@ export namespace LSPServer {
extensions: [".vue"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("vue-language-server")
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
@@ -260,7 +261,7 @@ export namespace LSPServer {
let lintBin = await resolveBin(lintTarget)
if (!lintBin) {
const found = Bun.which("oxlint")
const found = which("oxlint")
if (found) lintBin = found
}
@@ -281,7 +282,7 @@ export namespace LSPServer {
let serverBin = await resolveBin(serverTarget)
if (!serverBin) {
const found = Bun.which("oxc_language_server")
const found = which("oxc_language_server")
if (found) serverBin = found
}
if (serverBin) {
@@ -332,7 +333,7 @@ export namespace LSPServer {
let bin: string | undefined
if (await Filesystem.exists(localBin)) bin = localBin
if (!bin) {
const found = Bun.which("biome")
const found = which("biome")
if (found) bin = found
}
@@ -368,11 +369,11 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = Bun.which("gopls", {
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("go")) return
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing gopls")
@@ -405,12 +406,12 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = Bun.which("rubocop", {
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const ruby = Bun.which("ruby")
const gem = Bun.which("gem")
const ruby = which("ruby")
const gem = which("gem")
if (!ruby || !gem) {
log.info("Ruby not found, please install Ruby first")
return
@@ -457,7 +458,7 @@ export namespace LSPServer {
return undefined
}
let binary = Bun.which("ty")
let binary = which("ty")
const initialization: Record<string, string> = {}
@@ -509,7 +510,7 @@ export namespace LSPServer {
extensions: [".py", ".pyi"],
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
async spawn(root) {
let binary = Bun.which("pyright-langserver")
let binary = which("pyright-langserver")
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
@@ -563,7 +564,7 @@ export namespace LSPServer {
extensions: [".ex", ".exs"],
root: NearestRoot(["mix.exs", "mix.lock"]),
async spawn(root) {
let binary = Bun.which("elixir-ls")
let binary = which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
binary = path.join(
@@ -574,7 +575,7 @@ export namespace LSPServer {
)
if (!(await Filesystem.exists(binary))) {
const elixir = Bun.which("elixir")
const elixir = which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
return
@@ -625,12 +626,12 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = Bun.which("zls", {
let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const zig = Bun.which("zig")
const zig = which("zig")
if (!zig) {
log.error("Zig is required to use zls. Please install Zig first.")
return
@@ -737,11 +738,11 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = Bun.which("csharp-ls", {
let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("dotnet")) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
return
}
@@ -776,11 +777,11 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = Bun.which("fsautocomplete", {
let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("dotnet")) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
return
}
@@ -817,7 +818,7 @@ export namespace LSPServer {
async spawn(root) {
// Check if sourcekit-lsp is available in the PATH
// This is installed with the Swift toolchain
const sourcekit = Bun.which("sourcekit-lsp")
const sourcekit = which("sourcekit-lsp")
if (sourcekit) {
return {
process: spawn(sourcekit, {
@@ -828,7 +829,7 @@ export namespace LSPServer {
// If sourcekit-lsp not found, check if xcrun is available
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
if (!Bun.which("xcrun")) return
if (!which("xcrun")) return
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
@@ -877,7 +878,7 @@ export namespace LSPServer {
},
extensions: [".rs"],
async spawn(root) {
const bin = Bun.which("rust-analyzer")
const bin = which("rust-analyzer")
if (!bin) {
log.info("rust-analyzer not found in path, please install it")
return
@@ -896,7 +897,7 @@ export namespace LSPServer {
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
async spawn(root) {
const args = ["--background-index", "--clang-tidy"]
const fromPath = Bun.which("clangd")
const fromPath = which("clangd")
if (fromPath) {
return {
process: spawn(fromPath, args, {
@@ -1041,7 +1042,7 @@ export namespace LSPServer {
extensions: [".svelte"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("svelteserver")
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
@@ -1088,7 +1089,7 @@ export namespace LSPServer {
}
const tsdk = path.dirname(tsserver)
let binary = Bun.which("astro-ls")
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
@@ -1132,7 +1133,7 @@ export namespace LSPServer {
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
extensions: [".java"],
async spawn(root) {
const java = Bun.which("java")
const java = which("java")
if (!java) {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
return
@@ -1324,7 +1325,7 @@ export namespace LSPServer {
extensions: [".yaml", ".yml"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("yaml-language-server")
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
@@ -1380,7 +1381,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = Bun.which("lua-language-server", {
let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1512,7 +1513,7 @@ export namespace LSPServer {
extensions: [".php"],
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
async spawn(root) {
let binary = Bun.which("intelephense")
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
@@ -1556,7 +1557,7 @@ export namespace LSPServer {
extensions: [".prisma"],
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
async spawn(root) {
const prisma = Bun.which("prisma")
const prisma = which("prisma")
if (!prisma) {
log.info("prisma not found, please install prisma")
return
@@ -1574,7 +1575,7 @@ export namespace LSPServer {
extensions: [".dart"],
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
async spawn(root) {
const dart = Bun.which("dart")
const dart = which("dart")
if (!dart) {
log.info("dart not found, please install dart first")
return
@@ -1592,7 +1593,7 @@ export namespace LSPServer {
extensions: [".ml", ".mli"],
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
async spawn(root) {
const bin = Bun.which("ocamllsp")
const bin = which("ocamllsp")
if (!bin) {
log.info("ocamllsp not found, please install ocaml-lsp-server")
return
@@ -1609,7 +1610,7 @@ export namespace LSPServer {
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async () => Instance.directory,
async spawn(root) {
let binary = Bun.which("bash-language-server")
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
@@ -1648,7 +1649,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = Bun.which("terraform-ls", {
let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1731,7 +1732,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = Bun.which("texlab", {
let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1821,7 +1822,7 @@ export namespace LSPServer {
extensions: [".dockerfile", "Dockerfile"],
root: async () => Instance.directory,
async spawn(root) {
let binary = Bun.which("docker-langserver")
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
@@ -1860,7 +1861,7 @@ export namespace LSPServer {
extensions: [".gleam"],
root: NearestRoot(["gleam.toml"]),
async spawn(root) {
const gleam = Bun.which("gleam")
const gleam = which("gleam")
if (!gleam) {
log.info("gleam not found, please install gleam first")
return
@@ -1878,9 +1879,9 @@ export namespace LSPServer {
extensions: [".clj", ".cljs", ".cljc", ".edn"],
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
async spawn(root) {
let bin = Bun.which("clojure-lsp")
let bin = which("clojure-lsp")
if (!bin && process.platform === "win32") {
bin = Bun.which("clojure-lsp.exe")
bin = which("clojure-lsp.exe")
}
if (!bin) {
log.info("clojure-lsp not found, please install clojure-lsp first")
@@ -1909,7 +1910,7 @@ export namespace LSPServer {
return Instance.directory
},
async spawn(root) {
const nixd = Bun.which("nixd")
const nixd = which("nixd")
if (!nixd) {
log.info("nixd not found, please install nixd first")
return
@@ -1930,7 +1931,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = Bun.which("tinymist", {
let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -2024,7 +2025,7 @@ export namespace LSPServer {
extensions: [".hs", ".lhs"],
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
async spawn(root) {
const bin = Bun.which("haskell-language-server-wrapper")
const bin = which("haskell-language-server-wrapper")
if (!bin) {
log.info("haskell-language-server-wrapper not found, please install haskell-language-server")
return
@@ -2042,7 +2043,7 @@ export namespace LSPServer {
extensions: [".jl"],
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
async spawn(root) {
const julia = Bun.which("julia")
const julia = which("julia")
if (!julia) {
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
return

View File

@@ -4,6 +4,7 @@ import { Installation } from "../installation"
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { setTimeout as sleep } from "node:timers/promises"
const log = Log.create({ service: "plugin.codex" })
@@ -602,7 +603,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return { type: "failed" as const }
}
await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
}
},
}

View File

@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
import { setTimeout as sleep } from "node:timers/promises"
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// Add a small safety buffer when polling to avoid hitting the server
@@ -270,7 +271,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
}
if (data.error === "authorization_pending") {
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
@@ -286,13 +287,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
newInterval = serverInterval * 1000
}
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error) return { type: "failed" as const }
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
},

View File

@@ -14,6 +14,7 @@ import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -97,7 +98,7 @@ export namespace Project {
if (dotgit) {
let sandbox = path.dirname(dotgit)
const gitBinary = Bun.which("git")
const gitBinary = which("git")
// cached id calculation
let id = await Filesystem.readText(path.join(dotgit, "opencode"))

View File

@@ -263,13 +263,19 @@ export const SessionRoutes = lazy(() =>
sessionID: z.string(),
}),
),
validator(
"query",
z.object({
directory: z.string().optional(),
}),
),
validator(
"json",
z.object({
title: z.string().optional(),
time: z
.object({
archived: z.number().optional(),
archived: z.number().nullable().optional(),
})
.optional(),
}),
@@ -282,8 +288,8 @@ export const SessionRoutes = lazy(() =>
if (updates.title !== undefined) {
session = await Session.setTitle({ sessionID, title: updates.title })
}
if (updates.time?.archived !== undefined) {
session = await Session.setArchived({ sessionID, time: updates.time.archived })
if (updates.time !== undefined && "archived" in updates.time) {
session = await Session.setArchived({ sessionID, time: updates.time.archived ?? undefined })
}
return c.json(session)

View File

@@ -10,7 +10,7 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import { Database, NotFoundError, eq, and, or, gte, isNull, isNotNull, desc, like, inArray, lt } from "../storage/db"
import type { SQL } from "../storage/db"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
@@ -401,7 +401,7 @@ export namespace Session {
return Database.use((db) => {
const row = db
.update(SessionTable)
.set({ time_archived: input.time })
.set({ time_archived: input.time ?? null })
.where(eq(SessionTable.id, input.sessionID))
.returning()
.get()
@@ -599,6 +599,9 @@ export namespace Session {
if (input?.search) {
conditions.push(like(SessionTable.title, `%${input.search}%`))
}
if (input?.archived) {
conditions.push(isNotNull(SessionTable.time_archived))
}
if (!input?.archived) {
conditions.push(isNull(SessionTable.time_archived))
}

View File

@@ -315,11 +315,7 @@ export namespace SessionPrompt {
}
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
if (
lastAssistant?.finish &&
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
if (shouldExitLoop(lastUser, lastAssistant)) {
log.info("exiting loop", { sessionID })
break
}
@@ -1956,4 +1952,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return Session.setTitle({ sessionID: input.session.id, title })
}
}
/** @internal Exported for testing — determines whether the prompt loop should exit */
export function shouldExitLoop(
lastUser: MessageV2.User | undefined,
lastAssistant: MessageV2.Assistant | undefined,
): boolean {
if (!lastUser) return false
if (!lastAssistant?.finish) return false
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
return lastAssistant.parentID === lastUser.id
}
}

View File

@@ -5,7 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Only add comments if they are necessary to make a non-obvious block easier to understand.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- Prefer the edit tool for file edits. Use apply_patch only when it is available and clearly a better fit. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
## Tool usage
- Prefer specialized tools over shell for file operations:

View File

@@ -1,8 +1,10 @@
import { Flag } from "@/flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { which } from "@/util/which"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
const SIGKILL_TIMEOUT_MS = 200
@@ -22,13 +24,13 @@ export namespace Shell {
try {
process.kill(-pid, "SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
proc.kill("SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
@@ -39,7 +41,7 @@ export namespace Shell {
function fallback() {
if (process.platform === "win32") {
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
const git = Bun.which("git")
const git = which("git")
if (git) {
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
@@ -49,7 +51,7 @@ export namespace Shell {
return process.env.COMSPEC || "cmd.exe"
}
if (process.platform === "darwin") return "/bin/zsh"
const bash = Bun.which("bash")
const bash = which("bash")
if (bash) return bash
return "/bin/sh"
}

View File

@@ -5,6 +5,7 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
@@ -17,72 +18,158 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory"
import {
HashlineEdit,
applyHashlineEdits,
hashlineOnlyCreates,
parseHashlineContent,
serializeHashlineContent,
} from "./hashline"
import { Config } from "../config/config"
const MAX_DIAGNOSTICS_PER_FILE = 20
const LEGACY_EDIT_MODE = "legacy"
const HASHLINE_EDIT_MODE = "hashline"
const LegacyEditParams = z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
})
const HashlineEditParams = z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z.array(HashlineEdit).default([]),
delete: z.boolean().optional(),
rename: z.string().optional(),
})
const EditParams = z
.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().optional().describe("The text to replace"),
newString: z.string().optional().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
edits: z.array(HashlineEdit).optional(),
delete: z.boolean().optional(),
rename: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {
const legacy = value.oldString !== undefined || value.newString !== undefined || value.replaceAll !== undefined
const hashline = value.edits !== undefined || value.delete !== undefined || value.rename !== undefined
if (legacy && hashline) {
ctx.addIssue({
code: "custom",
message: "Do not mix legacy (oldString/newString) and hashline (edits/delete/rename) fields.",
})
return
}
if (!legacy && !hashline) {
ctx.addIssue({
code: "custom",
message: "Provide either legacy fields (oldString/newString) or hashline fields (edits/delete/rename).",
})
return
}
if (legacy) {
if (value.oldString === undefined || value.newString === undefined) {
ctx.addIssue({
code: "custom",
message: "Legacy payload requires both oldString and newString.",
})
}
return
}
if (value.edits === undefined) {
ctx.addIssue({
code: "custom",
message: "Hashline payload requires edits (use [] when only delete is intended).",
})
}
})
type LegacyEditParams = z.infer<typeof LegacyEditParams>
type HashlineEditParams = z.infer<typeof HashlineEditParams>
type EditParams = z.infer<typeof EditParams>
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
function isLegacyParams(params: EditParams): params is LegacyEditParams {
return params.oldString !== undefined || params.newString !== undefined || params.replaceAll !== undefined
}
async function withLocks(paths: string[], fn: () => Promise<void>) {
const unique = Array.from(new Set(paths)).sort((a, b) => a.localeCompare(b))
const recurse = async (idx: number): Promise<void> => {
if (idx >= unique.length) return fn()
await FileTime.withLock(unique[idx], () => recurse(idx + 1))
}
await recurse(0)
}
function createFileDiff(file: string, before: string, after: string): Snapshot.FileDiff {
const filediff: Snapshot.FileDiff = {
file,
before,
after,
additions: 0,
deletions: 0,
}
for (const change of diffLines(before, after)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
return filediff
}
async function diagnosticsOutput(filePath: string, output: string) {
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) {
return {
output,
diagnostics,
}
}
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
return {
output:
output +
`\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`,
diagnostics,
}
}
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filePath)
async function executeLegacy(params: LegacyEditParams, ctx: Tool.Context) {
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
let diff = ""
let contentOld = ""
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
FileTime.read(ctx.sessionID, filePath)
return
}
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filePath)
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
let diff = ""
let contentOld = ""
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
@@ -92,64 +179,319 @@ export const EditTool = Tool.define("edit", {
diff,
},
})
await Filesystem.write(filePath, contentNew)
await Filesystem.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
event: existed ? "change" : "add",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
FileTime.read(ctx.sessionID, filePath)
})
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
return
}
ctx.metadata({
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
filediff,
diagnostics: {},
},
})
let output = "Edit applied successfully."
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
await Filesystem.write(filePath, contentNew)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
FileTime.read(ctx.sessionID, filePath)
})
const filediff = createFileDiff(filePath, contentOld, contentNew)
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
edit_mode: LEGACY_EDIT_MODE,
},
})
const result = await diagnosticsOutput(filePath, "Edit applied successfully.")
return {
metadata: {
diagnostics: result.diagnostics,
diff,
filediff,
edit_mode: LEGACY_EDIT_MODE,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output: result.output,
}
}
async function executeHashline(
params: HashlineEditParams,
ctx: Tool.Context,
autocorrect: boolean,
aggressiveAutocorrect: boolean,
) {
const sourcePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
const targetPath = params.rename
? path.isAbsolute(params.rename)
? params.rename
: path.join(Instance.directory, params.rename)
: sourcePath
await assertExternalDirectory(ctx, sourcePath)
if (params.rename) {
await assertExternalDirectory(ctx, targetPath)
}
if (params.delete && params.edits.length > 0) {
throw new Error("delete=true cannot be combined with edits")
}
if (params.delete && params.rename) {
throw new Error("delete=true cannot be combined with rename")
}
let diff = ""
let before = ""
let after = ""
let noop = 0
let deleted = false
let changed = false
let diagnostics: Awaited<ReturnType<typeof LSP.diagnostics>> = {}
const paths = [sourcePath, targetPath]
await withLocks(paths, async () => {
const sourceStat = Filesystem.stat(sourcePath)
if (sourceStat?.isDirectory()) throw new Error(`Path is a directory, not a file: ${sourcePath}`)
const exists = Boolean(sourceStat)
if (params.rename && !exists) {
throw new Error("rename requires an existing source file")
}
if (params.delete) {
if (!exists) {
noop = 1
return
}
await FileTime.assert(ctx.sessionID, sourcePath)
before = await Filesystem.readText(sourcePath)
after = ""
diff = trimDiff(
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, sourcePath)],
always: ["*"],
metadata: {
filepath: sourcePath,
diff,
},
})
await fs.rm(sourcePath, { force: true })
await Bus.publish(File.Event.Edited, {
file: sourcePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: sourcePath,
event: "unlink",
})
deleted = true
changed = true
return
}
if (!exists && !hashlineOnlyCreates(params.edits)) {
throw new Error("Missing file can only be created with append/prepend hashline edits")
}
if (exists) {
await FileTime.assert(ctx.sessionID, sourcePath)
}
const parsed = exists
? parseHashlineContent(await Filesystem.readBytes(sourcePath))
: {
bom: false,
eol: "\n",
trailing: false,
lines: [] as string[],
text: "",
raw: "",
}
before = parsed.raw
const next = applyHashlineEdits({
lines: parsed.lines,
trailing: parsed.trailing,
edits: params.edits,
autocorrect,
aggressiveAutocorrect,
})
const output = serializeHashlineContent({
lines: next.lines,
trailing: next.trailing,
eol: parsed.eol,
bom: parsed.bom,
})
after = output.text
const noContentChange = before === after && sourcePath === targetPath
if (noContentChange) {
noop = 1
diff = trimDiff(
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
)
return
}
diff = trimDiff(
createTwoFilesPatch(sourcePath, targetPath, normalizeLineEndings(before), normalizeLineEndings(after)),
)
const patterns = [path.relative(Instance.worktree, sourcePath)]
if (sourcePath !== targetPath) patterns.push(path.relative(Instance.worktree, targetPath))
await ctx.ask({
permission: "edit",
patterns: Array.from(new Set(patterns)),
always: ["*"],
metadata: {
filepath: sourcePath,
diff,
},
})
if (sourcePath === targetPath) {
await Filesystem.write(sourcePath, output.bytes)
await Bus.publish(File.Event.Edited, {
file: sourcePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: sourcePath,
event: exists ? "change" : "add",
})
FileTime.read(ctx.sessionID, sourcePath)
changed = true
return
}
const targetExists = await Filesystem.exists(targetPath)
await Filesystem.write(targetPath, output.bytes)
await fs.rm(sourcePath, { force: true })
await Bus.publish(File.Event.Edited, {
file: sourcePath,
})
await Bus.publish(File.Event.Edited, {
file: targetPath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: sourcePath,
event: "unlink",
})
await Bus.publish(FileWatcher.Event.Updated, {
file: targetPath,
event: targetExists ? "change" : "add",
})
FileTime.read(ctx.sessionID, targetPath)
changed = true
})
const file = deleted ? sourcePath : targetPath
const filediff = createFileDiff(file, before, after)
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics,
edit_mode: HASHLINE_EDIT_MODE,
noop,
},
})
if (!deleted && (changed || noop === 0)) {
const result = await diagnosticsOutput(targetPath, noop > 0 ? "No changes applied." : "Edit applied successfully.")
diagnostics = result.diagnostics
return {
metadata: {
diagnostics,
diff,
filediff,
edit_mode: HASHLINE_EDIT_MODE,
noop,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output,
title: `${path.relative(Instance.worktree, targetPath)}`,
output: result.output,
}
}
return {
metadata: {
diagnostics,
diff,
filediff,
edit_mode: HASHLINE_EDIT_MODE,
noop,
},
title: `${path.relative(Instance.worktree, file)}`,
output: deleted ? "Edit applied successfully." : "No changes applied.",
}
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: EditParams,
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
}
if (isLegacyParams(params)) {
return executeLegacy(params, ctx)
}
const config = await Config.get()
if (config.experimental?.hashline_edit === false) {
throw new Error(
"Hashline edit payload is disabled. Set experimental.hashline_edit to true to use hashline operations.",
)
}
const hashlineParams: HashlineEditParams = {
filePath: params.filePath,
edits: params.edits ?? [],
delete: params.delete,
rename: params.rename,
}
return executeHashline(
hashlineParams,
ctx,
config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
)
},
})

View File

@@ -1,10 +1,33 @@
Performs exact string replacements in files.
Performs file edits with two supported payload schemas.
Usage:
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
- You must use your `Read` tool at least once before editing an existing file. This tool rejects stale edits when file contents changed since read.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
Legacy schema (always supported):
- `{ filePath, oldString, newString, replaceAll? }`
- Exact replacement only.
- The edit fails if `oldString` is not found.
- The edit fails if `oldString` matches multiple locations and `replaceAll` is not true.
- Use `replaceAll: true` for global replacements.
Hashline schema (default behavior):
- `{ filePath, edits, delete?, rename? }`
- Do not mix legacy fields (`oldString/newString/replaceAll`) with hashline fields (`edits/delete/rename`) in one call.
- Use strict anchor references from `Read` output: `LINE#ID`.
- Hashline mode can be turned off with `experimental.hashline_edit: false`.
- Autocorrect cleanup is on by default and can be turned off with `experimental.hashline_autocorrect: false`.
- Default autocorrect only strips copied `LINE#ID:`/`>>>` prefixes; set `OPENCODE_HL_AUTOCORRECT=1` to opt into heavier cleanup heuristics.
- When `Read` returns `LINE#ID:<content>`, prefer hashline operations.
- Operations:
- `set_line { line, text }`
- `replace_lines { start_line, end_line, text }`
- `insert_after { line, text }`
- `insert_before { line, text }`
- `insert_between { after_line, before_line, text }`
- `append { text }`
- `prepend { text }`
- `replace { old_text, new_text, all? }`
- In hashline mode, provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors.
- Fallback guidance: GPT-family models can use `apply_patch` as fallback; non-GPT models should fallback to legacy `oldString/newString` payloads.

View File

@@ -9,6 +9,8 @@ import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
import { Config } from "../config/config"
import { hashlineRef } from "./hashline"
const MAX_LINE_LENGTH = 2000
@@ -116,6 +118,7 @@ export const GrepTool = Tool.define("grep", {
}
const totalMatches = matches.length
const useHashline = (await Config.get()).experimental?.hashline_edit !== false
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
@@ -129,7 +132,11 @@ export const GrepTool = Tool.define("grep", {
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
if (useHashline) {
outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`)
} else {
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
}
if (truncated) {

View File

@@ -2,7 +2,8 @@
- Searches file contents using regular expressions
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
- Returns file paths and line numbers with at least one match sorted by modification time
- Returns file paths with matching lines sorted by modification time
- Output format follows edit mode: `Line N:` when hashline mode is disabled, `N#ID:<content>` when hashline mode is enabled
- Use this tool when you need to find files containing specific patterns
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead

View File

@@ -0,0 +1,646 @@
// hashline autocorrect heuristics in this file are inspired by
// https://github.com/can1357/oh-my-pi (mit license), adapted for opencode.
import z from "zod"
export const HASHLINE_ALPHABET = "ZPMQVRWSNKTXJBYH"
const HASHLINE_ID_LENGTH = 2
const HASHLINE_ID_REGEX = new RegExp(`^[${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}}$`)
const HASHLINE_REF_REGEX = new RegExp(`(\\d+)#([${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}})(?=$|\\s|:)`)
const LOW_SIGNAL_CONTENT_RE = /^[^a-zA-Z0-9]+$/
type TextValue = string | string[]
export const HashlineText = z.union([z.string(), z.array(z.string())])
export const HashlineEdit = z.discriminatedUnion("type", [
z
.object({
type: z.literal("set_line"),
line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("replace_lines"),
start_line: z.string(),
end_line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("insert_after"),
line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("insert_before"),
line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("insert_between"),
after_line: z.string(),
before_line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("append"),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("prepend"),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("replace"),
old_text: z.string(),
new_text: HashlineText,
all: z.boolean().optional(),
})
.strict(),
])
export type HashlineEdit = z.infer<typeof HashlineEdit>
function isLowSignalContent(normalized: string) {
if (normalized.length === 0) return true
if (normalized.length <= 2) return true
return LOW_SIGNAL_CONTENT_RE.test(normalized)
}
export function hashlineID(lineNumber: number, line: string): string {
let normalized = line
if (normalized.endsWith("\r")) normalized = normalized.slice(0, -1)
normalized = normalized.replace(/\s+/g, "")
const seed = isLowSignalContent(normalized) ? `${normalized}:${lineNumber}` : normalized
const hash = Bun.hash.xxHash32(seed) & 0xff
const high = (hash >>> 4) & 0x0f
const low = hash & 0x0f
return `${HASHLINE_ALPHABET[high]}${HASHLINE_ALPHABET[low]}`
}
export function hashlineRef(lineNumber: number, line: string): string {
return `${lineNumber}#${hashlineID(lineNumber, line)}`
}
export function hashlineLine(lineNumber: number, line: string): string {
return `${hashlineRef(lineNumber, line)}:${line}`
}
export function parseHashlineRef(input: string, label: string) {
const match = input.match(HASHLINE_REF_REGEX)
if (!match) {
throw new Error(`${label} must contain a LINE#ID reference`)
}
const line = Number.parseInt(match[1], 10)
if (!Number.isInteger(line) || line < 1) {
throw new Error(`${label} has invalid line number: ${match[1]}`)
}
const id = match[2]
if (!HASHLINE_ID_REGEX.test(id)) {
throw new Error(`${label} has invalid hash id: ${id}`)
}
return {
raw: `${line}#${id}`,
line,
id,
}
}
function toLines(text: TextValue) {
if (Array.isArray(text)) return text
return text.split(/\r?\n/)
}
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[ZPMQVRWSNKTXJBYH]{2}:/
const WRAPPER_PREFIX_RE = /^\s*(?:>>>|>>)\s?/
function stripByMajority(lines: string[], test: (line: string) => boolean, rewrite: (line: string) => string) {
const nonEmpty = lines.filter((line) => line.length > 0)
if (nonEmpty.length === 0) return lines
const matches = nonEmpty.filter(test).length
if (matches === 0 || matches < nonEmpty.length * 0.5) return lines
return lines.map(rewrite)
}
function stripNewLinePrefixes(lines: string[]) {
const stripped = stripByMajority(
lines,
(line) => HASHLINE_PREFIX_RE.test(line),
(line) => line.replace(HASHLINE_PREFIX_RE, ""),
)
return stripByMajority(
stripped,
(line) => WRAPPER_PREFIX_RE.test(line),
(line) => line.replace(WRAPPER_PREFIX_RE, ""),
)
}
function equalsIgnoringWhitespace(a: string, b: string) {
if (a === b) return true
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "")
}
function leadingWhitespace(line: string) {
const match = line.match(/^\s*/)
if (!match) return ""
return match[0]
}
function restoreLeadingIndent(template: string, line: string) {
if (line.length === 0) return line
const templateIndent = leadingWhitespace(template)
if (templateIndent.length === 0) return line
const indent = leadingWhitespace(line)
if (indent.length > 0) return line
return templateIndent + line
}
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]) {
if (oldLines.length !== newLines.length) return newLines
let changed = false
const out = new Array<string>(newLines.length)
for (let idx = 0; idx < newLines.length; idx++) {
const restored = restoreLeadingIndent(oldLines[idx], newLines[idx])
out[idx] = restored
if (restored !== newLines[idx]) changed = true
}
if (changed) return out
return newLines
}
function stripAllWhitespace(s: string) {
return s.replace(/\s+/g, "")
}
function restoreOldWrappedLines(oldLines: string[], newLines: string[]) {
if (oldLines.length === 0 || newLines.length < 2) return newLines
const canonToOld = new Map<string, { line: string; count: number }>()
for (const line of oldLines) {
const canon = stripAllWhitespace(line)
const bucket = canonToOld.get(canon)
if (bucket) bucket.count++
if (!bucket) canonToOld.set(canon, { line, count: 1 })
}
const candidates: Array<{ start: number; len: number; replacement: string; canon: string }> = []
for (let start = 0; start < newLines.length; start++) {
for (let len = 2; len <= 10 && start + len <= newLines.length; len++) {
const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join(""))
const old = canonToOld.get(canonSpan)
if (old && old.count === 1 && canonSpan.length >= 6) {
candidates.push({
start,
len,
replacement: old.line,
canon: canonSpan,
})
}
}
}
if (candidates.length === 0) return newLines
const canonCounts = new Map<string, number>()
for (const candidate of candidates) {
canonCounts.set(candidate.canon, (canonCounts.get(candidate.canon) ?? 0) + 1)
}
const unique = candidates.filter((candidate) => (canonCounts.get(candidate.canon) ?? 0) === 1)
if (unique.length === 0) return newLines
unique.sort((a, b) => b.start - a.start)
const out = [...newLines]
for (const candidate of unique) {
out.splice(candidate.start, candidate.len, candidate.replacement)
}
return out
}
function stripInsertAnchorEchoAfter(anchorLine: string, lines: string[]) {
if (lines.length <= 1) return lines
if (equalsIgnoringWhitespace(lines[0], anchorLine)) return lines.slice(1)
return lines
}
function stripInsertAnchorEchoBefore(anchorLine: string, lines: string[]) {
if (lines.length <= 1) return lines
if (equalsIgnoringWhitespace(lines[lines.length - 1], anchorLine)) return lines.slice(0, -1)
return lines
}
function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, lines: string[]) {
let out = lines
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) out = out.slice(1)
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) out = out.slice(0, -1)
return out
}
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, lines: string[]) {
const count = endLine - startLine + 1
if (lines.length <= 1 || lines.length <= count) return lines
let out = lines
const beforeIdx = startLine - 2
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) {
out = out.slice(1)
}
const afterIdx = endLine
if (
afterIdx < fileLines.length &&
out.length > 0 &&
equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])
) {
out = out.slice(0, -1)
}
return out
}
function ensureText(text: TextValue, label: string) {
const value = Array.isArray(text) ? text.join("") : text
if (value.length > 0) return
throw new Error(`${label} must be non-empty`)
}
function applyReplace(content: string, oldText: string, newText: TextValue, all = false) {
if (oldText.length === 0) throw new Error("replace.old_text must be non-empty")
const next = toLines(newText).join("\n")
const first = content.indexOf(oldText)
if (first < 0) throw new Error(`replace.old_text not found: ${JSON.stringify(oldText)}`)
if (all) return content.replaceAll(oldText, next)
const last = content.lastIndexOf(oldText)
if (first !== last) {
throw new Error("replace.old_text matched multiple times. Set all=true or provide a more specific old_text.")
}
return content.slice(0, first) + next + content.slice(first + oldText.length)
}
function mismatchContext(lines: string[], line: number) {
if (lines.length === 0) return ">>> (file is empty)"
const start = Math.max(1, line - 1)
const end = Math.min(lines.length, line + 1)
return Array.from({ length: end - start + 1 }, (_, idx) => start + idx)
.map((num) => {
const marker = num === line ? ">>>" : " "
return `${marker} ${hashlineLine(num, lines[num - 1])}`
})
.join("\n")
}
function mismatchSummary(lines: string[], mismatch: { expected: string; line: number }) {
if (mismatch.line < 1 || mismatch.line > lines.length) {
return `- expected ${mismatch.expected} -> line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`
}
return `- expected ${mismatch.expected} -> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`
}
function throwMismatch(lines: string[], mismatches: Array<{ expected: string; line: number }>) {
const seen = new Set<string>()
const unique = mismatches.filter((mismatch) => {
const key = `${mismatch.expected}:${mismatch.line}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const preview = unique.slice(0, 2).map((mismatch) => mismatchSummary(lines, mismatch))
const hidden = unique.length - preview.length
const count = unique.length
const linesOut = [
`Hashline edit rejected: ${count} anchor mismatch${count === 1 ? "" : "es"}. Re-read the file and retry with the updated anchors below.`,
...preview,
...(hidden > 0 ? [`- ... and ${hidden} more mismatches`] : []),
]
if (Bun.env.OPENCODE_HL_MISMATCH_DEBUG === "1") {
const body = unique
.map((mismatch) => {
if (mismatch.line < 1 || mismatch.line > lines.length) {
return [
`>>> expected ${mismatch.expected}`,
`>>> current line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`,
].join("\n")
}
return [
`>>> expected ${mismatch.expected}`,
mismatchContext(lines, mismatch.line),
`>>> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`,
].join("\n")
})
.join("\n\n")
linesOut.push("", body)
}
throw new Error(linesOut.join("\n"))
}
function validateAnchors(lines: string[], refs: Array<{ raw: string; line: number; id: string }>) {
const mismatches = refs
.filter((ref) => {
if (ref.line < 1 || ref.line > lines.length) return true
return hashlineID(ref.line, lines[ref.line - 1]) !== ref.id
})
.map((ref) => ({ expected: ref.raw, line: ref.line }))
if (mismatches.length > 0) throwMismatch(lines, mismatches)
}
function splitLines(text: string) {
if (text === "") {
return {
lines: [] as string[],
trailing: false,
}
}
const trailing = text.endsWith("\n")
const lines = text.split(/\r?\n/)
if (trailing) lines.pop()
return { lines, trailing }
}
export function parseHashlineContent(bytes: Buffer) {
const raw = bytes.toString("utf8")
let text = raw
const bom = raw.startsWith("\uFEFF")
if (bom) text = raw.slice(1)
const eol = text.includes("\r\n") ? "\r\n" : "\n"
const { lines, trailing } = splitLines(text)
return {
bom,
eol,
trailing,
lines,
text,
raw,
}
}
export function serializeHashlineContent(input: { lines: string[]; bom: boolean; eol: string; trailing: boolean }) {
let text = input.lines.join(input.eol)
if (input.trailing && input.lines.length > 0) text += input.eol
if (input.bom) text = `\uFEFF${text}`
return {
text,
bytes: Buffer.from(text, "utf8"),
}
}
type Splice = {
start: number
del: number
text: string[]
order: number
kind: "set_line" | "replace_lines" | "insert_after" | "insert_before" | "insert_between" | "append" | "prepend"
sortLine: number
precedence: number
startLine?: number
endLine?: number
anchorLine?: number
beforeLine?: number
afterLine?: number
}
export function applyHashlineEdits(input: {
lines: string[]
trailing: boolean
edits: HashlineEdit[]
autocorrect?: boolean
aggressiveAutocorrect?: boolean
}) {
const lines = [...input.lines]
const originalLines = [...input.lines]
let trailing = input.trailing
const refs: Array<{ raw: string; line: number; id: string }> = []
const replaceOps: Array<Extract<HashlineEdit, { type: "replace" }>> = []
const ops: Splice[] = []
const autocorrect = input.autocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
const aggressiveAutocorrect = input.aggressiveAutocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
const parseText = (text: TextValue) => {
const next = toLines(text)
if (!autocorrect) return next
return stripNewLinePrefixes(next)
}
input.edits.forEach((edit, order) => {
if (edit.type === "replace") {
replaceOps.push(edit)
return
}
if (edit.type === "append") {
ensureText(edit.text, "append.text")
ops.push({
start: lines.length,
del: 0,
text: parseText(edit.text),
order,
kind: "append",
sortLine: lines.length + 1,
precedence: 1,
})
return
}
if (edit.type === "prepend") {
ensureText(edit.text, "prepend.text")
ops.push({
start: 0,
del: 0,
text: parseText(edit.text),
order,
kind: "prepend",
sortLine: 0,
precedence: 2,
})
return
}
if (edit.type === "set_line") {
const line = parseHashlineRef(edit.line, "set_line.line")
refs.push(line)
ops.push({
start: line.line - 1,
del: 1,
text: parseText(edit.text),
order,
kind: "set_line",
sortLine: line.line,
precedence: 0,
startLine: line.line,
endLine: line.line,
})
return
}
if (edit.type === "replace_lines") {
const start = parseHashlineRef(edit.start_line, "replace_lines.start_line")
const end = parseHashlineRef(edit.end_line, "replace_lines.end_line")
refs.push(start)
refs.push(end)
if (start.line > end.line) {
throw new Error("replace_lines.start_line must be less than or equal to replace_lines.end_line")
}
ops.push({
start: start.line - 1,
del: end.line - start.line + 1,
text: parseText(edit.text),
order,
kind: "replace_lines",
sortLine: end.line,
precedence: 0,
startLine: start.line,
endLine: end.line,
})
return
}
if (edit.type === "insert_after") {
const line = parseHashlineRef(edit.line, "insert_after.line")
ensureText(edit.text, "insert_after.text")
refs.push(line)
ops.push({
start: line.line,
del: 0,
text: parseText(edit.text),
order,
kind: "insert_after",
sortLine: line.line,
precedence: 1,
anchorLine: line.line,
})
return
}
if (edit.type === "insert_before") {
const line = parseHashlineRef(edit.line, "insert_before.line")
ensureText(edit.text, "insert_before.text")
refs.push(line)
ops.push({
start: line.line - 1,
del: 0,
text: parseText(edit.text),
order,
kind: "insert_before",
sortLine: line.line,
precedence: 2,
anchorLine: line.line,
})
return
}
const after = parseHashlineRef(edit.after_line, "insert_between.after_line")
const before = parseHashlineRef(edit.before_line, "insert_between.before_line")
ensureText(edit.text, "insert_between.text")
refs.push(after)
refs.push(before)
if (after.line >= before.line) {
throw new Error("insert_between.after_line must be less than insert_between.before_line")
}
ops.push({
start: after.line,
del: 0,
text: parseText(edit.text),
order,
kind: "insert_between",
sortLine: before.line,
precedence: 3,
afterLine: after.line,
beforeLine: before.line,
})
})
validateAnchors(lines, refs)
const sorted = [...ops].sort((a, b) => {
if (a.sortLine !== b.sortLine) return b.sortLine - a.sortLine
if (a.precedence !== b.precedence) return a.precedence - b.precedence
return a.order - b.order
})
sorted.forEach((op) => {
if (op.start < 0 || op.start > lines.length) {
throw new Error(`line index ${op.start + 1} is out of range`)
}
let text = op.text
if (autocorrect && aggressiveAutocorrect) {
if (op.kind === "set_line" || op.kind === "replace_lines") {
const start = op.startLine ?? op.start + 1
const end = op.endLine ?? start + op.del - 1
const old = originalLines.slice(start - 1, end)
text = stripRangeBoundaryEcho(originalLines, start, end, text)
text = restoreOldWrappedLines(old, text)
text = restoreIndentForPairedReplacement(old, text)
}
if ((op.kind === "insert_after" || op.kind === "append") && op.anchorLine) {
text = stripInsertAnchorEchoAfter(originalLines[op.anchorLine - 1], text)
}
if ((op.kind === "insert_before" || op.kind === "prepend") && op.anchorLine) {
text = stripInsertAnchorEchoBefore(originalLines[op.anchorLine - 1], text)
}
if (op.kind === "insert_between" && op.afterLine && op.beforeLine) {
text = stripInsertBoundaryEcho(originalLines[op.afterLine - 1], originalLines[op.beforeLine - 1], text)
}
}
lines.splice(op.start, op.del, ...text)
})
if (replaceOps.length > 0) {
const content = `${lines.join("\n")}${trailing && lines.length > 0 ? "\n" : ""}`
const replaced = replaceOps.reduce(
(acc, op) =>
applyReplace(acc, op.old_text, autocorrect ? stripNewLinePrefixes(toLines(op.new_text)) : op.new_text, op.all),
content,
)
const split = splitLines(replaced)
lines.splice(0, lines.length, ...split.lines)
trailing = split.trailing
}
return {
lines,
trailing,
}
}
export function hashlineOnlyCreates(edits: HashlineEdit[]) {
return edits.every((edit) => edit.type === "append" || edit.type === "prepend")
}

View File

@@ -11,6 +11,8 @@ import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import { hashlineRef } from "./hashline"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -156,6 +158,7 @@ export const ReadTool = Tool.define("read", {
const offset = params.offset ?? 1
const start = offset - 1
const raw: string[] = []
const full: string[] = []
let bytes = 0
let lines = 0
let truncatedByBytes = false
@@ -179,6 +182,7 @@ export const ReadTool = Tool.define("read", {
}
raw.push(line)
full.push(text)
bytes += size
}
} finally {
@@ -190,8 +194,11 @@ export const ReadTool = Tool.define("read", {
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`)
}
const useHashline = (await Config.get()).experimental?.hashline_edit !== false
const content = raw.map((line, index) => {
return `${index + offset}: ${line}`
const lineNumber = index + offset
if (useHashline) return `${hashlineRef(lineNumber, full[index])}:${line}`
return `${lineNumber}: ${line}`
})
const preview = raw.slice(0, 20).join("\n")

View File

@@ -7,7 +7,10 @@ Usage:
- To read later sections, call this tool again with a larger offset.
- Use the grep tool to find specific content in large files or files with long lines.
- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
- Contents are returned with a line prefix.
- Default format: `LINE#ID:<content>` (example: `1#AB:foo`). Use these anchors for hashline edits.
- Legacy format can be restored with `experimental.hashline_edit: false`: `<line>: <content>` (example: `1: foo`).
- For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
- Any line longer than 2000 characters is truncated.
- Call this tool in parallel when you know there are multiple files you want to read.
- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.

View File

@@ -135,7 +135,11 @@ export namespace ToolRegistry {
},
agent?: Agent.Info,
) {
const config = await Config.get()
const tools = await all()
const hashline = config.experimental?.hashline_edit !== false
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
const result = await Promise.all(
tools
.filter((t) => {
@@ -144,9 +148,12 @@ export namespace ToolRegistry {
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
if (hashline) {
if (t.id === "apply_patch") return usePatch
return true
}
// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
if (t.id === "apply_patch") return usePatch
if (t.id === "edit" || t.id === "write") return !usePatch

View File

@@ -0,0 +1,10 @@
import whichPkg from "which"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const result = whichPkg.sync(cmd, {
nothrow: true,
path: env?.PATH,
pathExt: env?.PATHEXT,
})
return typeof result === "string" ? result : null
}

View File

@@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => {
expect(build).toBeDefined()
expect(build?.mode).toBe("primary")
expect(build?.native).toBe(true)
expect(evalPerm(build, "edit")).toBe("allow")
expect(evalPerm(build, "edit")).toBe("ask")
expect(evalPerm(build, "bash")).toBe("allow")
},
})
@@ -217,8 +217,8 @@ test("agent permission config merges with defaults", async () => {
expect(build).toBeDefined()
// Specific pattern is denied
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
// Edit still asks (default behavior)
expect(evalPerm(build, "edit")).toBe("ask")
},
})
})

View File

@@ -102,6 +102,28 @@ test("loads JSONC config file", async () => {
})
})
test("parses experimental.hashline_edit and experimental.hashline_autocorrect", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
experimental: {
hashline_edit: true,
hashline_autocorrect: true,
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.experimental?.hashline_edit).toBe(true)
expect(config.experimental?.hashline_autocorrect).toBe(true)
},
})
})
test("merges multiple config files with correct precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -3,6 +3,7 @@
import os from "os"
import path from "path"
import fs from "fs/promises"
import { setTimeout as sleep } from "node:timers/promises"
import { afterAll } from "bun:test"
// Set XDG env vars FIRST, before any src/ imports
@@ -15,7 +16,7 @@ afterAll(async () => {
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
const rm = async (left: number): Promise<void> => {
Bun.gc(true)
await Bun.sleep(100)
await sleep(100)
return fs.rm(dir, { recursive: true, force: true }).catch((error) => {
if (!busy(error)) throw error
if (left <= 1) throw error

View File

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
import { setTimeout as sleep } from "node:timers/promises"
describe("pty", () => {
test("does not leak output when websocket objects are reused", async () => {
@@ -43,7 +44,7 @@ describe("pty", () => {
// Output from a must never show up in b.
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -88,7 +89,7 @@ describe("pty", () => {
}
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -128,7 +129,7 @@ describe("pty", () => {
ctx.connId = 2
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
await sleep(100)
expect(out.join("")).toContain("AAA")
} finally {

View File

@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
function makeUser(id: string): MessageV2.User {
return {
id,
role: "user",
sessionID: "session-1",
time: { created: Date.now() },
agent: "default",
model: { providerID: "openai", modelID: "gpt-4" },
} as MessageV2.User
}
function makeAssistant(
id: string,
parentID: string,
finish?: string,
): MessageV2.Assistant {
return {
id,
role: "assistant",
sessionID: "session-1",
parentID,
mode: "default",
agent: "default",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: "gpt-4",
providerID: "openai",
time: { created: Date.now() },
finish,
} as MessageV2.Assistant
}
describe("shouldExitLoop", () => {
test("normal case: user ID < assistant ID, parentID matches, finish=end_turn → exits", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
})
test("clock skew: user ID > assistant ID, parentID matches, finish=stop → exits", () => {
// Simulates client clock ahead: user message ID sorts AFTER the assistant ID
const user = makeUser("01ZZZ")
const assistant = makeAssistant("01AAA", "01ZZZ", "stop")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true)
})
test("unfinished assistant: finish=tool-calls → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", "tool-calls")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("unfinished assistant: finish=unknown → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", "unknown")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("no assistant yet → does NOT exit", () => {
const user = makeUser("01AAA")
expect(SessionPrompt.shouldExitLoop(user, undefined)).toBe(false)
})
test("assistant has no finish → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01AAA", undefined)
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("parentID mismatch → does NOT exit", () => {
const user = makeUser("01AAA")
const assistant = makeAssistant("01BBB", "01OTHER", "end_turn")
expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false)
})
test("no user message → does NOT exit", () => {
const assistant = makeAssistant("01BBB", "01AAA", "end_turn")
expect(SessionPrompt.shouldExitLoop(undefined, assistant)).toBe(false)
})
})

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
@@ -135,7 +136,7 @@ describe("session.message-v2.fromError", () => {
new ReadableStream({
async pull(controller) {
controller.enqueue("Hello,")
await Bun.sleep(10000)
await sleep(10000)
controller.enqueue(" World!")
controller.close()
},

View File

@@ -5,6 +5,7 @@ import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time"
import { hashlineLine, hashlineRef } from "../../src/tool/hashline"
const ctx = {
sessionID: "test-edit-session",
@@ -493,4 +494,286 @@ describe("tool.edit", () => {
})
})
})
describe("hashline payload", () => {
test("replaces a single line in hashline mode", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
edits: [
{
type: "set_line",
line: hashlineRef(2, "b"),
text: "B",
},
],
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("a\nB\nc")
expect(result.metadata.edit_mode).toBe("hashline")
},
})
})
test("applies hashline autocorrect prefixes through config", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
hashline_autocorrect: true,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "set_line",
line: hashlineRef(2, "b"),
text: hashlineLine(2, "B"),
},
],
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("a\nB\nc")
},
})
})
test("supports range replacement and insert modes", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc\nd", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "replace_lines",
start_line: hashlineRef(2, "b"),
end_line: hashlineRef(3, "c"),
text: ["B", "C"],
},
{
type: "insert_before",
line: hashlineRef(2, "b"),
text: "x",
},
{
type: "insert_after",
line: hashlineRef(3, "c"),
text: "y",
},
],
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("a\nx\nB\nC\ny\nd")
},
})
})
test("creates missing files from append/prepend operations", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
const filepath = path.join(tmp.path, "created.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "prepend",
text: "start",
},
{
type: "append",
text: "end",
},
],
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("start\nend")
},
})
})
test("rejects missing files for non-append/prepend edits", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
const filepath = path.join(tmp.path, "missing.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
edits: [
{
type: "replace",
old_text: "a",
new_text: "b",
},
],
},
ctx,
),
).rejects.toThrow("Missing file can only be created")
},
})
})
test("supports delete and rename flows", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "src.txt"), "a\nb", "utf-8")
await fs.writeFile(path.join(dir, "delete.txt"), "delete me", "utf-8")
},
})
const source = path.join(tmp.path, "src.txt")
const target = path.join(tmp.path, "renamed.txt")
const doomed = path.join(tmp.path, "delete.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
FileTime.read(ctx.sessionID, source)
await edit.execute(
{
filePath: source,
rename: target,
edits: [
{
type: "set_line",
line: hashlineRef(2, "b"),
text: "B",
},
],
},
ctx,
)
expect(await fs.readFile(target, "utf-8")).toBe("a\nB")
await expect(fs.stat(source)).rejects.toThrow()
FileTime.read(ctx.sessionID, doomed)
await edit.execute(
{
filePath: doomed,
delete: true,
edits: [],
},
ctx,
)
await expect(fs.stat(doomed)).rejects.toThrow()
},
})
})
test("rejects hashline payload when experimental mode is disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
edits: [
{
type: "append",
text: "b",
},
],
},
ctx,
),
).rejects.toThrow("Hashline edit payload is disabled")
},
})
})
})
})

View File

@@ -37,6 +37,62 @@ describe("tool.grep", () => {
})
})
test("hashline disabled keeps Line N format", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await GrepTool.init()
const result = await grep.execute(
{
pattern: "alpha",
path: tmp.path,
},
ctx,
)
expect(result.output).toContain("Line 1: alpha")
},
})
})
test("hashline enabled emits N#ID anchor format", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
init: async (dir) => {
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await GrepTool.init()
const result = await grep.execute(
{
pattern: "alpha",
path: tmp.path,
},
ctx,
)
expect(result.output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:alpha\b/)
},
})
})
test("no matches returns correct output", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -0,0 +1,210 @@
import { describe, expect, test } from "bun:test"
import { applyHashlineEdits, hashlineID, hashlineLine, hashlineRef, parseHashlineRef } from "../../src/tool/hashline"
function swapID(ref: string) {
const [line, id] = ref.split("#")
const next = id[0] === "Z" ? `P${id[1]}` : `Z${id[1]}`
return `${line}#${next}`
}
function errorMessage(run: () => void) {
try {
run()
return ""
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
describe("tool.hashline", () => {
test("hash computation is stable and 2-char alphabet encoded", () => {
const a = hashlineID(1, " const x = 1")
const b = hashlineID(1, "constx=1")
const c = hashlineID(99, "constx=1")
expect(a).toBe(b)
expect(a).toBe(c)
expect(a).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/)
})
test("low-signal lines mix line index into hash id", () => {
const a = hashlineID(1, "")
const b = hashlineID(2, "")
const c = hashlineID(1, "{}")
const d = hashlineID(2, "{}")
expect(a).not.toBe(b)
expect(c).not.toBe(d)
})
test("autocorrect strips copied hashline prefixes when enabled", () => {
const old = Bun.env.OPENCODE_HL_AUTOCORRECT
Bun.env.OPENCODE_HL_AUTOCORRECT = "1"
try {
const result = applyHashlineEdits({
lines: ["a"],
trailing: false,
edits: [
{
type: "set_line",
line: hashlineRef(1, "a"),
text: hashlineLine(1, "a"),
},
],
})
expect(result.lines).toEqual(["a"])
} finally {
if (old === undefined) delete Bun.env.OPENCODE_HL_AUTOCORRECT
else Bun.env.OPENCODE_HL_AUTOCORRECT = old
}
})
test("default autocorrect does not rewrite non-prefix content", () => {
const result = applyHashlineEdits({
lines: ["a"],
trailing: false,
edits: [
{
type: "set_line",
line: hashlineRef(1, "a"),
text: "+a",
},
],
autocorrect: true,
aggressiveAutocorrect: false,
})
expect(result.lines).toEqual(["+a"])
})
test("parses strict LINE#ID references with tolerant extraction", () => {
const ref = parseHashlineRef(">>> 12#ZP:const value = 1", "line")
expect(ref.line).toBe(12)
expect(ref.id).toBe("ZP")
expect(ref.raw).toBe("12#ZP")
expect(() => parseHashlineRef("12#ab", "line")).toThrow("LINE#ID")
})
test("reports compact mismatch errors with retry anchors", () => {
const lines = ["alpha", "beta", "gamma"]
const wrong = swapID(hashlineRef(2, lines[1]))
const message = errorMessage(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "set_line",
line: wrong,
text: "BETA",
},
],
}),
)
expect(message).toContain("anchor mismatch")
expect(message).toContain("retry with")
expect(message).not.toContain(">>>")
expect(message.length).toBeLessThan(260)
})
test("applies batched line edits bottom-up for stable results", () => {
const lines = ["a", "b", "c", "d"]
const one = hashlineRef(1, lines[0])
const two = hashlineRef(2, lines[1])
const three = hashlineRef(3, lines[2])
const four = hashlineRef(4, lines[3])
const result = applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "replace_lines",
start_line: two,
end_line: three,
text: ["B", "C"],
},
{
type: "insert_after",
line: one,
text: "A1",
},
{
type: "set_line",
line: four,
text: "D",
},
],
})
expect(result.lines).toEqual(["a", "A1", "B", "C", "D"])
})
test("orders append and prepend deterministically on empty files", () => {
const result = applyHashlineEdits({
lines: [],
trailing: false,
edits: [
{
type: "append",
text: "end",
},
{
type: "prepend",
text: "start",
},
],
})
expect(result.lines).toEqual(["start", "end"])
})
test("validates ranges, between constraints, and non-empty insert text", () => {
const lines = ["a", "b", "c"]
const one = hashlineRef(1, lines[0])
const two = hashlineRef(2, lines[1])
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "replace_lines",
start_line: two,
end_line: one,
text: "x",
},
],
}),
).toThrow("start_line")
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "insert_between",
after_line: two,
before_line: one,
text: "x",
},
],
}),
).toThrow("insert_between.after_line")
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "append",
text: "",
},
],
}),
).toThrow("append.text")
})
})

View File

@@ -6,6 +6,7 @@ import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next"
import { Agent } from "../../src/agent/agent"
import { hashlineLine } from "../../src/tool/hashline"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -269,10 +270,10 @@ describe("tool.read truncation", () => {
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
expect(result.output).toContain("10: line10")
expect(result.output).toContain("14: line14")
expect(result.output).not.toContain("9: line10")
expect(result.output).not.toContain("15: line15")
expect(result.output).toContain(hashlineLine(10, "line10"))
expect(result.output).toContain(hashlineLine(14, "line14"))
expect(result.output).not.toContain(hashlineLine(9, "line9"))
expect(result.output).not.toContain(hashlineLine(15, "line15"))
expect(result.output).toContain("line10")
expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0")
@@ -443,6 +444,50 @@ root_type Monster;`
})
})
describe("tool.read hashline output", () => {
test("returns LINE#ID prefixes by default", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "hashline.txt"), "foo\nbar")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "hashline.txt") }, ctx)
expect(result.output).toContain(hashlineLine(1, "foo"))
expect(result.output).toContain(hashlineLine(2, "bar"))
expect(result.output).not.toContain("1: foo")
},
})
})
test("keeps legacy line prefixes when hashline mode is disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
init: async (dir) => {
await Bun.write(path.join(dir, "legacy.txt"), "foo\nbar")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "legacy.txt") }, ctx)
expect(result.output).toContain("1: foo")
expect(result.output).toContain("2: bar")
},
})
})
})
describe("tool.read loaded instructions", () => {
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
await using tmp = await tmpdir({

View File

@@ -0,0 +1,100 @@
import { describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ToolRegistry } from "../../src/tool/registry"
describe("tool.registry hashline routing", () => {
test("hashline mode keeps edit and apply_patch for GPT models", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "openai",
modelID: "gpt-5",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).toContain("write")
expect(ids).toContain("apply_patch")
},
})
})
test("hashline mode keeps edit and removes apply_patch for non-GPT models", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "anthropic",
modelID: "claude-3-7-sonnet",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).toContain("write")
expect(ids).not.toContain("apply_patch")
},
})
})
test("keeps existing GPT apply_patch routing when hashline is explicitly disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "openai",
modelID: "gpt-5",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("apply_patch")
expect(ids).not.toContain("edit")
},
})
})
test("keeps existing non-GPT routing when hashline is explicitly disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "anthropic",
modelID: "claude-3-7-sonnet",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).not.toContain("apply_patch")
},
})
})
})

View File

@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { which } from "../../src/util/which"
import { tmpdir } from "../fixture/fixture"
async function cmd(dir: string, name: string, exec = true) {
const ext = process.platform === "win32" ? ".cmd" : ""
const file = path.join(dir, name + ext)
const body = process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"
await fs.writeFile(file, body)
if (process.platform !== "win32") {
await fs.chmod(file, exec ? 0o755 : 0o644)
}
return file
}
function env(PATH: string): NodeJS.ProcessEnv {
return {
PATH,
PATHEXT: process.env["PATHEXT"],
}
}
function same(a: string | null, b: string) {
if (process.platform === "win32") {
expect(a?.toLowerCase()).toBe(b.toLowerCase())
return
}
expect(a).toBe(b)
}
describe("util.which", () => {
test("returns null when command is missing", () => {
expect(which("opencode-missing-command-for-test")).toBeNull()
})
test("finds a command from PATH override", async () => {
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = await cmd(bin, "tool")
same(which("tool", env(bin)), file)
})
test("uses first PATH match", async () => {
await using tmp = await tmpdir()
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
await fs.mkdir(a)
await fs.mkdir(b)
const first = await cmd(a, "dupe")
await cmd(b, "dupe")
same(which("dupe", env([a, b].join(path.delimiter))), first)
})
test("returns null for non-executable file on unix", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
await cmd(bin, "noexec", false)
expect(which("noexec", env(bin))).toBeNull()
})
test("uses PATHEXT on windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = path.join(bin, "pathext.CMD")
await fs.writeFile(file, "@echo off\r\n")
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
})
})

View File

@@ -1008,6 +1008,396 @@ export type GlobalEvent = {
payload: Event
}
/**
* Custom keybind configurations
*/
export type KeybindsConfig = {
/**
* Leader key for keybind combinations
*/
leader?: string
/**
* Exit the application
*/
app_exit?: string
/**
* Open external editor
*/
editor_open?: string
/**
* List available themes
*/
theme_list?: string
/**
* Toggle sidebar
*/
sidebar_toggle?: string
/**
* Toggle session scrollbar
*/
scrollbar_toggle?: string
/**
* Toggle username visibility
*/
username_toggle?: string
/**
* View status
*/
status_view?: string
/**
* Export session to editor
*/
session_export?: string
/**
* Create a new session
*/
session_new?: string
/**
* List all sessions
*/
session_list?: string
/**
* Show session timeline
*/
session_timeline?: string
/**
* Fork session from message
*/
session_fork?: string
/**
* Rename session
*/
session_rename?: string
/**
* Delete session
*/
session_delete?: string
/**
* Delete stash entry
*/
stash_delete?: string
/**
* Open provider list from model dialog
*/
model_provider_list?: string
/**
* Toggle model favorite status
*/
model_favorite_toggle?: string
/**
* Toggle showing all models
*/
model_show_all_toggle?: string
/**
* Share current session
*/
session_share?: string
/**
* Unshare current session
*/
session_unshare?: string
/**
* Interrupt current session
*/
session_interrupt?: string
/**
* Compact the session
*/
session_compact?: string
/**
* Scroll messages up by one page
*/
messages_page_up?: string
/**
* Scroll messages down by one page
*/
messages_page_down?: string
/**
* Scroll messages up by one line
*/
messages_line_up?: string
/**
* Scroll messages down by one line
*/
messages_line_down?: string
/**
* Scroll messages up by half page
*/
messages_half_page_up?: string
/**
* Scroll messages down by half page
*/
messages_half_page_down?: string
/**
* Navigate to first message
*/
messages_first?: string
/**
* Navigate to last message
*/
messages_last?: string
/**
* Navigate to next message
*/
messages_next?: string
/**
* Navigate to previous message
*/
messages_previous?: string
/**
* Navigate to last user message
*/
messages_last_user?: string
/**
* Copy message
*/
messages_copy?: string
/**
* Undo message
*/
messages_undo?: string
/**
* Redo message
*/
messages_redo?: string
/**
* Toggle code block concealment in messages
*/
messages_toggle_conceal?: string
/**
* Toggle tool details visibility
*/
tool_details?: string
/**
* List available models
*/
model_list?: string
/**
* Next recently used model
*/
model_cycle_recent?: string
/**
* Previous recently used model
*/
model_cycle_recent_reverse?: string
/**
* Next favorite model
*/
model_cycle_favorite?: string
/**
* Previous favorite model
*/
model_cycle_favorite_reverse?: string
/**
* List available commands
*/
command_list?: string
/**
* List agents
*/
agent_list?: string
/**
* Next agent
*/
agent_cycle?: string
/**
* Previous agent
*/
agent_cycle_reverse?: string
/**
* Toggle auto-accept mode for permissions
*/
permission_auto_accept_toggle?: string
/**
* Cycle model variants
*/
variant_cycle?: string
/**
* Clear input field
*/
input_clear?: string
/**
* Paste from clipboard
*/
input_paste?: string
/**
* Submit input
*/
input_submit?: string
/**
* Insert newline in input
*/
input_newline?: string
/**
* Move cursor left in input
*/
input_move_left?: string
/**
* Move cursor right in input
*/
input_move_right?: string
/**
* Move cursor up in input
*/
input_move_up?: string
/**
* Move cursor down in input
*/
input_move_down?: string
/**
* Select left in input
*/
input_select_left?: string
/**
* Select right in input
*/
input_select_right?: string
/**
* Select up in input
*/
input_select_up?: string
/**
* Select down in input
*/
input_select_down?: string
/**
* Move to start of line in input
*/
input_line_home?: string
/**
* Move to end of line in input
*/
input_line_end?: string
/**
* Select to start of line in input
*/
input_select_line_home?: string
/**
* Select to end of line in input
*/
input_select_line_end?: string
/**
* Move to start of visual line in input
*/
input_visual_line_home?: string
/**
* Move to end of visual line in input
*/
input_visual_line_end?: string
/**
* Select to start of visual line in input
*/
input_select_visual_line_home?: string
/**
* Select to end of visual line in input
*/
input_select_visual_line_end?: string
/**
* Move to start of buffer in input
*/
input_buffer_home?: string
/**
* Move to end of buffer in input
*/
input_buffer_end?: string
/**
* Select to start of buffer in input
*/
input_select_buffer_home?: string
/**
* Select to end of buffer in input
*/
input_select_buffer_end?: string
/**
* Delete line in input
*/
input_delete_line?: string
/**
* Delete to end of line in input
*/
input_delete_to_line_end?: string
/**
* Delete to start of line in input
*/
input_delete_to_line_start?: string
/**
* Backspace in input
*/
input_backspace?: string
/**
* Delete character in input
*/
input_delete?: string
/**
* Undo in input
*/
input_undo?: string
/**
* Redo in input
*/
input_redo?: string
/**
* Move word forward in input
*/
input_word_forward?: string
/**
* Move word backward in input
*/
input_word_backward?: string
/**
* Select word forward in input
*/
input_select_word_forward?: string
/**
* Select word backward in input
*/
input_select_word_backward?: string
/**
* Delete word forward in input
*/
input_delete_word_forward?: string
/**
* Delete word backward in input
*/
input_delete_word_backward?: string
/**
* Previous history item
*/
history_previous?: string
/**
* Next history item
*/
history_next?: string
/**
* Next child session
*/
session_child_cycle?: string
/**
* Previous child session
*/
session_child_cycle_reverse?: string
/**
* Go to parent session
*/
session_parent?: string
/**
* Suspend terminal
*/
terminal_suspend?: string
/**
* Toggle terminal title
*/
terminal_title_toggle?: string
/**
* Toggle tips on home screen
*/
tips_toggle?: string
/**
* Toggle thinking blocks visibility
*/
display_thinking?: string
}
/**
* Log level
*/

View File

@@ -1,9 +1,12 @@
import { defineMain } from "storybook-solidjs-vite"
import path from "node:path"
import { fileURLToPath } from "node:url"
import tailwindcss from "@tailwindcss/vite"
const here = path.dirname(fileURLToPath(import.meta.url))
const ui = path.resolve(here, "../../ui")
const app = path.resolve(here, "../../app/src")
const mocks = path.resolve(here, "./mocks")
export default defineMain({
framework: {
@@ -21,15 +24,41 @@ export default defineMain({
async viteFinal(config) {
const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
return mergeConfig(config, {
plugins: [tailwindcss()],
resolve: {
dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
alias: [
{ find: "@solidjs/router", replacement: path.resolve(mocks, "solid-router.tsx") },
{ find: /^@\/context\/local$/, replacement: path.resolve(mocks, "app/context/local.ts") },
{ find: /^@\/context\/file$/, replacement: path.resolve(mocks, "app/context/file.ts") },
{ find: /^@\/context\/prompt$/, replacement: path.resolve(mocks, "app/context/prompt.ts") },
{ find: /^@\/context\/layout$/, replacement: path.resolve(mocks, "app/context/layout.ts") },
{ find: /^@\/context\/sdk$/, replacement: path.resolve(mocks, "app/context/sdk.ts") },
{ find: /^@\/context\/sync$/, replacement: path.resolve(mocks, "app/context/sync.ts") },
{ find: /^@\/context\/comments$/, replacement: path.resolve(mocks, "app/context/comments.ts") },
{ find: /^@\/context\/command$/, replacement: path.resolve(mocks, "app/context/command.ts") },
{ find: /^@\/context\/permission$/, replacement: path.resolve(mocks, "app/context/permission.ts") },
{ find: /^@\/context\/language$/, replacement: path.resolve(mocks, "app/context/language.ts") },
{ find: /^@\/context\/platform$/, replacement: path.resolve(mocks, "app/context/platform.ts") },
{ find: /^@\/context\/global-sync$/, replacement: path.resolve(mocks, "app/context/global-sync.ts") },
{ find: /^@\/hooks\/use-providers$/, replacement: path.resolve(mocks, "app/hooks/use-providers.ts") },
{
find: /^@\/components\/dialog-select-model$/,
replacement: path.resolve(mocks, "app/components/dialog-select-model.tsx"),
},
{
find: /^@\/components\/dialog-select-model-unpaid$/,
replacement: path.resolve(mocks, "app/components/dialog-select-model-unpaid.tsx"),
},
{ find: "@", replacement: app },
],
},
worker: {
format: "es",
},
server: {
fs: {
allow: [searchForWorkspaceRoot(process.cwd()), ui],
allow: [searchForWorkspaceRoot(process.cwd()), ui, app, mocks],
},
},
})

View File

@@ -0,0 +1,3 @@
export function DialogSelectModelUnpaid() {
return <div data-component="dialog-select-model-unpaid">Select model</div>
}

View File

@@ -0,0 +1,7 @@
import { splitProps } from "solid-js"
export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Record<string, unknown>; children: any }) {
const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"])
const Trigger = local.triggerAs
return <Trigger {...(local.triggerProps ?? {})}>{local.children}</Trigger>
}

View File

@@ -0,0 +1,22 @@
const keybinds: Record<string, string> = {
"file.attach": "mod+u",
"prompt.mode.shell": "mod+shift+x",
"prompt.mode.normal": "mod+shift+e",
"permissions.autoaccept": "mod+shift+a",
"agent.cycle": "mod+.",
"model.choose": "mod+m",
"model.variant.cycle": "mod+shift+m",
}
export function useCommand() {
return {
options: [],
register() {
return () => undefined
},
trigger() {},
keybind(id: string) {
return keybinds[id]
},
}
}

View File

@@ -0,0 +1,34 @@
import { createSignal } from "solid-js"
type Comment = {
id: string
file: string
selection: { start: number; end: number }
comment: string
time: number
}
const [list, setList] = createSignal<Comment[]>([])
const [focus, setFocus] = createSignal<{ file: string; id: string } | null>(null)
const [active, setActive] = createSignal<{ file: string; id: string } | null>(null)
export function useComments() {
return {
all: list,
replace(next: Comment[]) {
setList(next)
},
remove(file: string, id: string) {
setList((current) => current.filter((item) => !(item.file === file && item.id === id)))
},
clear() {
setList([])
setFocus(null)
setActive(null)
},
focus,
setFocus,
active,
setActive,
}
}

View File

@@ -0,0 +1,47 @@
export type FileSelection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export type SelectedLineRange = {
start: number
end: number
}
export function selectionFromLines(selection?: SelectedLineRange): FileSelection | undefined {
if (!selection) return undefined
return {
startLine: selection.start,
startChar: 0,
endLine: selection.end,
endChar: 0,
}
}
const pool = [
"src/session/timeline.tsx",
"src/session/composer.tsx",
"src/components/prompt-input.tsx",
"src/components/session-todo-dock.tsx",
"README.md",
]
export function useFile() {
return {
tab(path: string) {
return `file:${path}`
},
pathFromTab(tab: string) {
if (!tab.startsWith("file:")) return ""
return tab.slice(5)
},
load: async () => undefined,
async searchFilesAndDirectories(query: string) {
const text = query.trim().toLowerCase()
if (!text) return pool
return pool.filter((path) => path.toLowerCase().includes(text))
},
}
}

View File

@@ -0,0 +1,42 @@
import { createStore } from "solid-js/store"
const provider = {
all: [
{
id: "anthropic",
models: {
"claude-3-7-sonnet": {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
cost: { input: 1, output: 1 },
},
},
},
],
connected: ["anthropic"],
default: { anthropic: "claude-3-7-sonnet" },
}
const [store, setStore] = createStore({
todo: {} as Record<string, any[]>,
provider,
session: [] as any[],
config: { permission: {} },
})
export function useGlobalSync() {
return {
data: {
provider,
session_todo: store.todo,
},
child() {
return [store, setStore] as const
},
todo: {
set(sessionID: string, todos: any[]) {
setStore("todo", sessionID, todos)
},
},
}
}

View File

@@ -0,0 +1,74 @@
const dict: Record<string, string> = {
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse todos",
"session.todo.expand": "Expand todos",
"prompt.loading": "Loading prompt...",
"prompt.placeholder.normal": "Ask anything...",
"prompt.placeholder.simple": "Ask anything...",
"prompt.placeholder.shell": "Run a shell command...",
"prompt.placeholder.summarizeComment": "Summarize this comment",
"prompt.placeholder.summarizeComments": "Summarize these comments",
"prompt.action.attachFile": "Attach file",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
"prompt.attachment.remove": "Remove attachment",
"prompt.dropzone.label": "Drop image to attach",
"prompt.dropzone.file.label": "Drop file to attach",
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"dialog.model.select.title": "Select model",
"common.default": "Default",
"common.key.esc": "Esc",
"command.category.file": "File",
"command.category.session": "Session",
"command.agent.cycle": "Cycle agent",
"command.model.choose": "Choose model",
"command.model.variant.cycle": "Cycle model variant",
"command.prompt.mode.shell": "Switch to shell mode",
"command.prompt.mode.normal": "Switch to prompt mode",
"command.permissions.autoaccept.enable": "Enable auto-accept",
"command.permissions.autoaccept.disable": "Disable auto-accept",
"prompt.example.1": "Refactor this function and keep behavior the same",
"prompt.example.2": "Find the root cause of this error",
"prompt.example.3": "Write tests for this module",
"prompt.example.4": "Explain this diff",
"prompt.example.5": "Optimize this query",
"prompt.example.6": "Clean up this component",
"prompt.example.7": "Summarize the recent changes",
"prompt.example.8": "Add accessibility checks",
"prompt.example.9": "Review this API design",
"prompt.example.10": "Generate migration notes",
"prompt.example.11": "Patch this bug",
"prompt.example.12": "Make this animation smoother",
"prompt.example.13": "Improve error handling",
"prompt.example.14": "Document this feature",
"prompt.example.15": "Refine these styles",
"prompt.example.16": "Check edge cases",
"prompt.example.17": "Help me write a commit message",
"prompt.example.18": "Reduce re-renders in this component",
"prompt.example.19": "Verify keyboard navigation",
"prompt.example.20": "Make this copy clearer",
"prompt.example.21": "Add telemetry for this flow",
"prompt.example.22": "Compare these two implementations",
"prompt.example.23": "Create a minimal reproduction",
"prompt.example.24": "Suggest naming improvements",
"prompt.example.25": "What should we test next?",
}
function render(template: string, params?: Record<string, unknown>) {
if (!params) return template
return template.replace(/\{\{([^}]+)\}\}/g, (_, key: string) => {
const value = params[key.trim()]
if (value === undefined || value === null) return ""
return String(value)
})
}
export function useLanguage() {
return {
locale: () => "en" as const,
t(key: string, params?: Record<string, unknown>) {
return render(dict[key] ?? key, params)
},
}
}

View File

@@ -0,0 +1,41 @@
import { createSignal } from "solid-js"
const [all, setAll] = createSignal<string[]>([])
const [active, setActive] = createSignal<string | undefined>(undefined)
const [reviewOpen, setReviewOpen] = createSignal(false)
const tabs = {
all,
active,
open(tab: string) {
setAll((current) => (current.includes(tab) ? current : [...current, tab]))
},
setActive(tab: string) {
if (!all().includes(tab)) {
tabs.open(tab)
}
setActive(tab)
},
}
const view = {
reviewPanel: {
opened: reviewOpen,
open() {
setReviewOpen(true)
},
},
}
export function useLayout() {
return {
tabs: () => tabs,
view: () => view,
fileTree: {
setTab() {},
},
handoff: {
setTabs() {},
},
}
}

View File

@@ -0,0 +1,41 @@
import { createSignal } from "solid-js"
const model = {
id: "claude-3-7-sonnet",
name: "Claude 3.7 Sonnet",
provider: { id: "anthropic" },
variants: { fast: {}, thinking: {} },
}
const agents = [{ name: "build" }, { name: "review" }, { name: "plan" }]
const [agent, setAgent] = createSignal(agents[0].name)
const [variant, setVariant] = createSignal<string | undefined>(undefined)
export function useLocal() {
return {
slug: () => "c3Rvcnk=",
agent: {
list: () => agents,
current: () => agents.find((item) => item.name === agent()) ?? agents[0],
set(value?: string) {
if (!value) {
setAgent(agents[0].name)
return
}
const hit = agents.find((item) => item.name === value)
setAgent(hit?.name ?? agents[0].name)
},
},
model: {
current: () => model,
variant: {
list: () => Object.keys(model.variants),
current: () => variant(),
set(next?: string) {
setVariant(next)
},
},
},
}
}

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