Compare commits

...

93 Commits

Author SHA1 Message Date
Ryan Vogel
2a75aadf02 update to have rechecking for compliance 2026-02-18 21:32:19 -05:00
Ryan Vogel
7560913b7d finalize to copy the style of the PR review for enhanced security 2026-02-18 21:27:16 -05:00
Ryan Vogel
ba02738d34 init stage 2026-02-18 21:26:58 -05:00
Dax Raad
3a416f6f33 sdk: fix nested exports transformation in publish script
The publish script now recursively transforms export paths to handle nested export objects. This ensures all SDK entry points are correctly mapped to their compiled dist/ locations and type definitions when publishing to npm.
2026-02-18 20:43:50 -05:00
Dax Raad
11a37834c2 tui: ensure onExit callback fires after terminal output is written 2026-02-18 20:43:50 -05:00
Brendan Allan
d620455531 app: deduplicate allServers list 2026-02-19 09:34:23 +08:00
Dax Raad
568eccb4c6 Revert: all refactor commits migrating from Bun.file() to Filesystem module 2026-02-18 19:41:14 -05:00
Dax
3a07dd8d96 refactor: migrate src/project/project.ts from Bun.file() to Filesystem/stat modules (#14126) 2026-02-18 19:37:10 -05:00
David Hill
802ccd3788 tweak(ui): rotate collapsible chevron icon 2026-02-19 00:35:12 +00:00
David Hill
6042785c57 tweak(ui): rtl-truncate edited file paths 2026-02-19 00:32:12 +00:00
David Hill
5d8664c13e tweak(app): adjust session turn horizontal padding 2026-02-19 00:32:12 +00:00
David Hill
3d0f24067c tweak(app): tighten prompt dock padding 2026-02-19 00:32:12 +00:00
David Hill
44049540b0 tweak(ui): add open-file tooltip icon 2026-02-19 00:32:12 +00:00
David Hill
40f00ccc1c tweak(ui): use chevron icons for review diff rows 2026-02-19 00:32:12 +00:00
David Hill
a301051263 tweak(ui): tighten review diff file info gap 2026-02-19 00:32:12 +00:00
David Hill
fd61be4078 tweak(ui): show added diff counts in review 2026-02-19 00:32:12 +00:00
David Hill
4a8bdc3c75 tweak(ui): group edited files list styling 2026-02-19 00:32:12 +00:00
David Hill
9c7629ce61 Update oc-2.json 2026-02-19 00:32:12 +00:00
David Hill
ba53c56a21 tweak(ui): combine diffs in review into one group 2026-02-19 00:32:12 +00:00
Dax
14c0989411 refactor: migrate src/tool/read.ts from Bun.file() to Filesystem module (#14118) 2026-02-18 19:29:11 -05:00
Dax
36bc07a5af refactor: migrate src/tool/write.ts from Bun.file() to Filesystem module (#14119) 2026-02-18 19:27:11 -05:00
Dax
270b807cdf refactor: migrate src/tool/edit.ts from Bun.file() to Filesystem module (#14120) 2026-02-18 19:26:45 -05:00
Dax
bd52ce5640 refactor: migrate remaining tool files from Bun.file() to Filesystem/stat modules (#14121) 2026-02-18 19:24:21 -05:00
Dax
a624871ccd refactor: migrate src/storage/storage.ts from Bun.file()/Bun.write() to Filesystem module (#14122) 2026-02-18 19:21:21 -05:00
Dax
819d09e64e refactor: migrate src/storage/json-migration.ts from Bun.file() to Filesystem module (#14123) 2026-02-18 19:20:40 -05:00
Dax
9e6cb89101 refactor: migrate src/mcp/auth.ts from Bun.file()/Bun.write() to Filesystem module (#14125) 2026-02-18 19:20:16 -05:00
Dax
a8347c3762 refactor: migrate src/storage/db.ts from Bun.file() to statSync (#14124) 2026-02-18 19:20:03 -05:00
Dax
57b63ea83d refactor: migrate src/session/prompt.ts from Bun.file() to Filesystem/stat modules (#14128) 2026-02-19 00:18:05 +00:00
Ryan Vogel
c162074888 chore: skip PR standards checks for PRs created before Feb 18 2026 6PM EST (#14208) 2026-02-18 18:16:43 -05:00
Aiden Cline
088eac9d4e fix: opencode run crashing, and show errored tool calls in output (#14206) 2026-02-18 17:13:01 -06:00
Dax
5fe237a3fd refactor: migrate src/skill/discovery.ts from Bun.file()/Bun.write() to Filesystem module (#14133) 2026-02-18 23:10:24 +00:00
Dax
ae398539c5 refactor: migrate src/session/instruction.ts from Bun.file() to Filesystem module (#14130) 2026-02-18 18:09:45 -05:00
Dax
359360ad86 refactor: migrate src/provider/provider.ts from Bun.file() to Filesystem module (#14132) 2026-02-18 18:08:48 -05:00
Dax
5d12eb9528 refactor: migrate src/shell/shell.ts from Bun.file() to statSync (#14134) 2026-02-18 22:55:50 +00:00
Dax
6fb4f2a7a5 refactor: migrate src/cli/cmd/tui/thread.ts from Bun.file() to Filesystem module (#14135) 2026-02-18 17:28:41 -05:00
Dax
48dfa45a9a refactor: migrate src/util/log.ts from Bun.file() to Node.js fs module (#14136) 2026-02-18 17:28:08 -05:00
Dax
97520c827e refactor: migrate src/provider/models.ts from Bun.file()/Bun.write() to Filesystem module (#14131) 2026-02-18 17:26:13 -05:00
Dax
b75a89776d refactor: migrate src/lsp/client.ts from Bun.file() to Filesystem module (#14137) 2026-02-18 17:22:06 -05:00
opencode-agent[bot]
b909679367 chore: generate 2026-02-18 22:21:17 +00:00
Ryan Vogel
639d1dd8fe chore: add compliance checks for issues and PRs with recheck on edit (#14170) 2026-02-18 17:20:23 -05:00
Luke Parker
7033b4d0a8 fix(win32): Sidecar spawning a window (#14197) 2026-02-19 08:18:15 +10:00
Eduardo Bellido Bellido
87c16374aa fix(lsp): use HashiCorp releases API for installing terraform-ls (#14200) 2026-02-18 16:11:57 -06:00
Dax
d366a1430f refactor: migrate src/lsp/server.ts from Bun.file()/Bun.write() to Filesystem module (#14138) 2026-02-18 21:41:07 +00:00
David Hill
cfea5c73de tweak(app): delay prompt mode toggle tooltip 2026-02-18 21:33:14 +00:00
David Hill
2589eb207f tweak(app): shorten prompt mode toggle tooltips 2026-02-18 21:33:14 +00:00
David Hill
ec7c72da3f tweak(ui): restyle reasoning blocks 2026-02-18 21:33:14 +00:00
Dax
a4b36a72ad refactor: migrate src/file/time.ts from Bun.file() to stat (#14141) 2026-02-18 21:22:08 +00:00
Dax
e37a9081a6 refactor: migrate src/cli/cmd/session.ts from Bun.file() to statSync (#14144) 2026-02-18 16:20:58 -05:00
Dax
a2469d933e refactor: migrate src/acp/agent.ts from Bun.file() to Filesystem module (#14139) 2026-02-18 16:14:20 -05:00
Dax
3cde93bf2d refactor: migrate src/auth/index.ts from Bun.file()/Bun.write() to Filesystem module (#14140) 2026-02-18 16:13:50 -05:00
Dax
898bcdec87 refactor: migrate src/cli/cmd/agent.ts from Bun.file()/Bun.write() to Filesystem module (#14142) 2026-02-18 21:08:01 +00:00
Dax
d5971e2da5 refactor: migrate src/cli/cmd/import.ts from Bun.file() to Filesystem module (#14143) 2026-02-18 21:07:32 +00:00
David Hill
c71f4d4847 Update oc-2.json 2026-02-18 20:32:54 +00:00
opencode-agent[bot]
dec7827548 chore: generate 2026-02-18 20:19:27 +00:00
David Hill
7faa8cb110 tweak(ui): reduce review panel padding 2026-02-18 20:18:17 +00:00
David Hill
d8a4a125c0 Update oc-2.json 2026-02-18 20:18:17 +00:00
David Hill
50923f06f1 tweak(ui): remove pressed scale for secondary buttons 2026-02-18 20:18:17 +00:00
David Hill
ba919fb619 tweak(ui): shrink review expand/collapse width 2026-02-18 20:18:17 +00:00
David Hill
47b4de3531 tweak(ui): tighten review header action spacing 2026-02-18 20:18:17 +00:00
David Hill
bb6d1d502f tweak(ui): adjust review diff style hover radius 2026-02-18 20:18:17 +00:00
David Hill
31e964e7cf Update oc-2.json 2026-02-18 20:18:17 +00:00
David Hill
06b2304a5f tweak(ui): override for the radio group in the review 2026-02-18 20:18:17 +00:00
David Hill
1b67339e4d Update radio-group.css 2026-02-18 20:18:17 +00:00
David Hill
1571246ba8 tweak(ui): use default cursor for segmented control 2026-02-18 20:18:17 +00:00
David Hill
d730d8be01 tweak(ui): shrink review diff style toggle 2026-02-18 20:18:17 +00:00
David Hill
e42cc85112 Update oc-2.json 2026-02-18 20:18:17 +00:00
David Hill
c7a79f1877 Update icon-button.css 2026-02-18 20:18:17 +00:00
David Hill
431f5347af tweak(ui): search button style 2026-02-18 20:18:17 +00:00
David Hill
1ed4a98233 tweak(ui): remove pressed transition for secondary buttons 2026-02-18 20:18:17 +00:00
David Hill
db4ff89579 Update oc-2.json 2026-02-18 20:18:17 +00:00
David Hill
2f56761060 tweak(ui): expanded color state on titlebar buttons 2026-02-18 20:18:17 +00:00
David Hill
09286ccae0 tweak(ui): oc-2 theme updates 2026-02-18 20:18:17 +00:00
David Hill
4e959849f6 tweak(ui): hover and active styles for filetree tabs 2026-02-18 20:18:17 +00:00
David Hill
3690cafeb8 tweak(ui): hover and active styles for title bar buttons 2026-02-18 20:18:17 +00:00
David Hill
bcca253dec tweak(ui): hover and active styles for title bar buttons 2026-02-18 20:18:17 +00:00
David Hill
6d69ad5574 tweak(ui): update oc-2 secondary button colors 2026-02-18 20:18:17 +00:00
David Hill
1f9be63e96 tweak(ui): use weak border and base icon color for secondary 2026-02-18 20:18:17 +00:00
David Hill
0873908030 tweak(ui): theme color updates 2026-02-18 20:18:17 +00:00
David Hill
4db2d94854 tweak(ui): shrink filetree tab height 2026-02-18 20:18:17 +00:00
David Hill
e5d52e4eb5 tweak(ui): align pill tabs pressed background 2026-02-18 20:18:16 +00:00
David Hill
f20c0bffd3 tweak(ui): unify titlebar expanded button background 2026-02-18 20:18:16 +00:00
David Hill
9110e6a2a7 tweak(ui): share button border 2026-02-18 20:18:16 +00:00
David Hill
0888c02379 tweak(ui): file tree background color 2026-02-18 20:18:16 +00:00
David Hill
24ce49d9d7 fix(ui): add previous smoke colors 2026-02-18 20:18:16 +00:00
David Hill
5d69f00282 button style tweaks 2026-02-18 20:18:16 +00:00
David Hill
12016c8eb4 oc-2 theme init 2026-02-18 20:18:16 +00:00
David Hill
d6331cf792 Update colors.css 2026-02-18 20:18:16 +00:00
opencode-agent[bot]
2d7c9c9692 chore: generate 2026-02-18 20:15:14 +00:00
Helge Tesdal
1aa18c6cd6 feat(plugin): pass sessionID and callID to shell.env hook input (#13662) 2026-02-18 14:14:18 -06:00
Adam
de25703e9d fix(app): terminal cross-talk (#14184) 2026-02-18 13:56:05 -06:00
Adam
1133d87be0 chore: cleanup 2026-02-18 13:38:51 -06:00
Adam
42aa28d512 chore: cleanup (#14181) 2026-02-18 13:23:20 -06:00
opencode-agent[bot]
c6bd320003 chore: update nix node_modules hashes 2026-02-18 19:08:12 +00:00
77 changed files with 2375 additions and 772 deletions

15
.github/TEAM_MEMBERS vendored Normal file
View File

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

View File

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

View File

@@ -2,10 +2,11 @@ name: duplicate-issues
on:
issues:
types: [opened]
types: [opened, edited]
jobs:
check-duplicates:
if: github.event.action == 'opened'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
@@ -18,100 +19,186 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Install dependencies
run: bun install
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Build prompt
uses: actions/github-script@v8
with:
script: |
const fs = require("fs")
const issue = context.payload.issue
const body = issue.body ?? ""
const text = [
"Check this new issue for compliance and duplicates:",
"",
`CURRENT_ISSUE_NUMBER: ${issue.number}`,
"",
`Title: ${issue.title}`,
"",
"Description:",
body,
].join("\n")
fs.writeFileSync("issue_info.txt", text)
- name: Check duplicates and compliance
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow"
},
"webfetch": "deny"
}
run: |
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
bun script/duplicate-issue.ts -f issue_info.txt "Check the attached file for issue details and return either a comment body or No action required" > issue_comment.txt
Issue number: ${{ github.event.issue.number }}
- name: Post comment and label issue
env:
COMMENT: ${{ github.workspace }}/issue_comment.txt
uses: actions/github-script@v8
with:
script: |
const fs = require("fs")
const comment = fs.readFileSync(process.env.COMMENT, "utf8").trim()
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
if (comment === "No action required") {
core.info("No comment needed")
return
}
You have TWO tasks. Perform both, then post a SINGLE comment (if needed).
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: `_The following comment was made by an LLM, it may be inaccurate:_\n\n${comment}`,
})
---
if (!comment.includes("<!-- issue-compliance -->")) return
TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: ["needs:compliance"],
})
Check whether the issue follows our contributing guidelines and issue templates.
recheck-compliance:
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
This project has three issue templates that every issue MUST use one of:
- uses: ./.github/actions/setup-bun
1. Bug Report - requires a Description field with real content
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
3. Question - requires the Question field with real content
- name: Install dependencies
run: bun install
Additionally check:
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
- The issue has real content, not just template placeholder text left unchanged
- Bug reports should include some context about how to reproduce
- Feature requests should explain the problem or need
- We want to push for having the user provide system description & information
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
- name: Build recheck prompt
uses: actions/github-script@v8
with:
script: |
const fs = require("fs")
const issue = context.payload.issue
const body = issue.body ?? ""
const text = [
"Recheck this edited issue for compliance:",
"",
"MODE: recheck-compliance",
`CURRENT_ISSUE_NUMBER: ${issue.number}`,
"",
`Title: ${issue.title}`,
"",
"Description:",
body,
].join("\n")
---
fs.writeFileSync("issue_info.txt", text)
TASK 2: DUPLICATE CHECK
- name: Recheck compliance
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
bun script/duplicate-issue.ts -f issue_info.txt "Recheck compliance for this edited issue and return either No action required or a compliance comment body" > issue_comment.txt
Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
Consider:
1. Similar titles or descriptions
2. Same error messages or symptoms
3. Related functionality or components
4. Similar feature requests
- name: Update compliance state
env:
COMMENT: ${{ github.workspace }}/issue_comment.txt
uses: actions/github-script@v8
with:
script: |
const fs = require("fs")
const marker = "<!-- issue-compliance -->"
const comment = fs.readFileSync(process.env.COMMENT, "utf8").trim()
const issue_number = context.payload.issue.number
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
per_page: 100,
})
---
const compliance = comments.filter((x) => (x.body ?? "").includes(marker))
POSTING YOUR COMMENT:
if (comment === "No action required") {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
name: "needs:compliance",
})
} catch {}
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
for (const entry of compliance) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: entry.id,
})
}
If the issue is NOT compliant, start the comment with:
<!-- issue-compliance -->
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
body: "Thanks for updating your issue. It now meets our contributing guidelines. :+1:",
})
return
}
If duplicates were found, include a section about potential duplicates with links.
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
labels: ["needs:compliance"],
})
If the issue mentions keybinds/keyboard shortcuts, include a note about #4997.
const body = `_The following comment was made by an LLM, it may be inaccurate:_\n\n${comment}`
const existing = compliance.at(-1)
if (!existing) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
body,
})
return
}
If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all.
Use this format for the comment:
[If not compliant:]
<!-- issue-compliance -->
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
**What needs to be fixed:**
- [specific reasons]
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
[If duplicates found, add:]
---
This issue might be a duplicate of existing issues. Please check:
- #[issue_number]: [brief description of similarity]
[If keybind-related, add:]
For keybind-related issues, please also check our pinned keybinds documentation: #4997
[End with if not compliant:]
If you believe this was flagged incorrectly, please let a maintainer know.
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
})

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
color: "#E67E22"
tools:
"*": false
"github-issue-search": true
---
You are a duplicate issue detection agent. When an issue is opened, your job is to search for potentially duplicate or related open issues.
You have two jobs:
1. Check if the issue follows our issue templates/contributing requirements.
2. Check for potential duplicate issues.
Use the github-issue-search tool to find potentially related issues.
IMPORTANT: The input will contain a line `CURRENT_ISSUE_NUMBER: NNNN`. Never mark that issue as a duplicate of itself.
The input may also contain `MODE: recheck-compliance`.
- When MODE is `recheck-compliance`, ONLY run compliance checks. Do not run duplicate checks. Do not include duplicate or keybind sections.
- When MODE is missing, do the full opened-issue behavior (compliance + duplicates + keybind note).
## Compliance checks
This project has three issue templates:
1. Bug Report - needs a Description field with real content.
2. Feature Request - title should start with `[FEATURE]:` and include verification checkbox + meaningful description.
3. Question - needs a Question field with real content.
Also check:
- no AI-generated walls of text
- required sections are not placeholder-only / unchanged template text
- bug reports include some repro context
- feature requests explain the problem/need
- encourage system information where relevant
Do not be nitpicky about optional fields. Only flag real issues (missing template/required content, placeholder-only content, obviously AI-generated wall of text, empty/nonsensical issue).
## Duplicate checks
Search for duplicates by trying multiple keyword combinations from the issue title/body. Prioritize:
- similar title/description
- same error/symptoms
- same component/feature area
If the issue mentions keybinds, keyboard shortcuts, or key bindings, include a note to check pinned issue #4997.
## Output rules
If MODE is `recheck-compliance` and the issue is compliant, output exactly:
No action required
If MODE is missing and the issue is compliant AND no duplicates are found AND no keybind note is needed, output exactly:
No action required
Otherwise output exactly one markdown comment body with this structure:
- In `recheck-compliance` mode: include only the non-compliant section and ending note.
- In default mode: include sections as needed (non-compliant, duplicates, keybind).
- If non-compliant, start with:
<!-- issue-compliance -->
This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
**What needs to be fixed:**
- [specific reason]
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
- If duplicates were found, add:
---
This issue might be a duplicate of existing issues. Please check:
- #1234: [brief reason]
- If keybind-related, add:
For keybind-related issues, please also check our pinned keybinds documentation: #4997
- If non-compliant, end with:
If you believe this was flagged incorrectly, please let a maintainer know.
Keep output concise. Do not wrap output in code fences.

View File

@@ -0,0 +1,57 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-issue-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
},
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
interface Issue {
title: string
html_url: string
}
export default tool({
description: DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query for issue titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
offset: tool.schema.number().describe("Number of results to skip for pagination").default(0),
},
async execute(args) {
const owner = "anomalyco"
const repo = "opencode"
const page = Math.floor(args.offset / args.limit) + 1
const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:issue state:open`)
const result = await githubFetch(
`/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`,
)
if (result.total_count === 0) {
return `No issues found matching "${args.query}"`
}
const issues = result.items as Issue[]
if (issues.length === 0) {
return `No other issues found matching "${args.query}"`
}
const formatted = issues.map((issue) => `${issue.title}\n${issue.html_url}`).join("\n\n")
return `Found ${result.total_count} issues (showing ${issues.length}):\n\n${formatted}`
},
})

View File

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

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-5sXHoHbRdXbqM/zRJZiXt26sm/yyyZN/4OOHUtdofhk=",
"aarch64-linux": "sha256-JCMm5X7e27BBV4wyaknCMM4CBt4Lr72SSvaGxEeNsJE=",
"aarch64-darwin": "sha256-DBQJURlTPqFt0OYUHSvZZ4H0NUf020aic4zNX5CXzDc=",
"x86_64-darwin": "sha256-t2luVxqCcRSgq/WNWkm4ZpKXO22n2RnAWP6msoTOr+A="
"x86_64-linux": "sha256-7y6gQyIxyrdp2DaG/0oOEpuL+1n9oa8arUn1CuDiDhA=",
"aarch64-linux": "sha256-7dnHO2WqQZ9A8cG3EC8p7408YR9n2F5C6DG5rNWHqNY=",
"aarch64-darwin": "sha256-jxjhnVfE61RVOHaWvDO4mGLk6guQ8jHeXv/pbu5nbaE=",
"x86_64-darwin": "sha256-22yM4FEtVxGWRug6H0rKog86Q/cYE3QsADrRbLeJKVQ="
}
}

View File

@@ -427,6 +427,7 @@ export function DialogSelectServer() {
}
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
@@ -446,8 +447,8 @@ export function DialogSelectServer() {
>
<ServerRow
conn={i}
status={store.status[ServerConnection.key(i)]}
dimmed={store.status[ServerConnection.key(i)]?.healthy === false}
status={store.status[key]}
dimmed={store.status[key]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i.http.url}>
@@ -460,7 +461,7 @@ export function DialogSelectServer() {
</Show>
<Show when={store.editServer.id !== i.http.url}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<Show when={ServerConnection.key(current()) === key}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>

View File

@@ -403,15 +403,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
createEffect(() => {
if (!isFocused()) closePopover()
})
// Safety: reset composing state on focus change to prevent stuck state
// This handles edge cases where compositionend event may not fire
createEffect(() => {
if (!isFocused()) setComposing(false)
})
const handleBlur = () => {
closePopover()
setComposing(false)
}
const agentList = createMemo(() =>
sync.data.agent
@@ -1118,6 +1113,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
@@ -1367,7 +1363,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<TooltipKeybind
placement="top"
gutter={4}
title={language.t(mode === "shell" ? "command.prompt.mode.shell" : "command.prompt.mode.normal")}
openDelay={2000}
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
class="size-full flex items-center justify-center"
>

View File

@@ -257,27 +257,12 @@ export function SessionHeader() {
] as const
})
const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true
const list = apps()
return list.every((app) => exists[app.id] !== undefined)
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
createEffect(() => {
if (platform.platform !== "desktop") return
if (!checksReady()) return
const value = prefs.app
if (options().some((o) => o.id === value)) return
setPrefs("app", options()[0]?.id ?? "finder")
})
const openDir = (app: OpenApp) => {
const directory = projectDirectory()
if (!directory) return
@@ -319,9 +304,11 @@ export function SessionHeader() {
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<button
<Button
type="button"
class="hidden md:flex w-[240px] max-w-full min-w-0 h-[24px] pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
@@ -337,7 +324,7 @@ export function SessionHeader() {
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
)}
</Show>
</button>
</Button>
</Portal>
)}
</Show>
@@ -398,7 +385,7 @@ export function SessionHeader() {
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
value={prefs.app}
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
setPrefs("app", value as OpenApp)
@@ -464,7 +451,7 @@ export function SessionHeader() {
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
classList: { "rounded-r-none": share.shareUrl() !== undefined },
style: { scale: 1 },
}}
@@ -537,7 +524,7 @@ export function SessionHeader() {
<IconButton
icon={share.state.copied ? "check" : "link"}
variant="ghost"
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={share.state.unshare}
aria-label={

View File

@@ -203,7 +203,7 @@ export function StatusPopover() {
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-hover",
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
style: { scale: 1 },
}}
trigger={

View File

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

View File

@@ -102,15 +102,19 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}),
)
const allServers = createMemo(
(): Array<ServerConnection.Any> => [
const allServers = createMemo((): Array<ServerConnection.Any> => {
const servers = [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
],
)
]
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
return [...deduped.values()]
})
const [state, setState] = createStore({
active: props.defaultServer,

View File

@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
"command.model.variant.cycle": "تغيير جهد التفكير",
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
"command.prompt.mode.shell": "التبديل إلى وضع Shell",
"command.prompt.mode.normal": "التبديل إلى وضع Prompt",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل",

View File

@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Mudar para o agente anterior",
"command.model.variant.cycle": "Alternar nível de raciocínio",
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
"command.prompt.mode.shell": "Alternar para o modo Shell",
"command.prompt.mode.normal": "Alternar para o modo Prompt",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
"command.workspace.toggle": "Alternar espaços de trabalho",

View File

@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta",
"command.model.variant.cycle": "Promijeni nivo razmišljanja",
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
"command.prompt.mode.shell": "Prebaci na Shell način",
"command.prompt.mode.normal": "Prebaci na Prompt način",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
"command.workspace.toggle": "Prikaži/sakrij radne prostore",

View File

@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Skift til forrige agent",
"command.model.variant.cycle": "Skift tænkeindsats",
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
"command.prompt.mode.shell": "Skift til shell-tilstand",
"command.prompt.mode.normal": "Skift til prompt-tilstand",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
"command.workspace.toggle": "Skift arbejdsområder",

View File

@@ -67,8 +67,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Zum vorherigen Agenten wechseln",
"command.model.variant.cycle": "Denkaufwand wechseln",
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
"command.prompt.mode.shell": "In den Shell-Modus wechseln",
"command.prompt.mode.normal": "In den Prompt-Modus wechseln",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
"command.workspace.toggle": "Arbeitsbereiche umschalten",

View File

@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Switch to the previous agent",
"command.model.variant.cycle": "Cycle thinking effort",
"command.model.variant.cycle.description": "Switch to the next effort level",
"command.prompt.mode.shell": "Switch to shell mode",
"command.prompt.mode.normal": "Switch to prompt mode",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Auto-accept edits",
"command.permissions.autoaccept.disable": "Stop auto-accepting edits",
"command.workspace.toggle": "Toggle workspaces",

View File

@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Cambiar al agente anterior",
"command.model.variant.cycle": "Alternar esfuerzo de pensamiento",
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
"command.prompt.mode.shell": "Cambiar al modo Shell",
"command.prompt.mode.normal": "Cambiar al modo Prompt",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
"command.workspace.toggle": "Alternar espacios de trabajo",

View File

@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Passer à l'agent précédent",
"command.model.variant.cycle": "Changer l'effort de réflexion",
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
"command.prompt.mode.shell": "Passer en mode Shell",
"command.prompt.mode.normal": "Passer en mode Prompt",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
"command.workspace.toggle": "Basculer les espaces de travail",

View File

@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "前のエージェントに切り替え",
"command.model.variant.cycle": "思考レベルの切り替え",
"command.model.variant.cycle.description": "次の思考レベルに切り替え",
"command.prompt.mode.shell": "シェルモードに切り替える",
"command.prompt.mode.normal": "プロンプトモードに切り替える",
"command.prompt.mode.shell": "シェル",
"command.prompt.mode.normal": "プロンプト",
"command.permissions.autoaccept.enable": "編集を自動承認",
"command.permissions.autoaccept.disable": "編集の自動承認を停止",
"command.workspace.toggle": "ワークスペースを切り替え",

View File

@@ -67,8 +67,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "이전 에이전트로 전환",
"command.model.variant.cycle": "생각 수준 순환",
"command.model.variant.cycle.description": "다음 생각 수준으로 전환",
"command.prompt.mode.shell": "셸 모드로 전환",
"command.prompt.mode.normal": "프롬프트 모드로 전환",
"command.prompt.mode.shell": "셸",
"command.prompt.mode.normal": "프롬프트",
"command.permissions.autoaccept.enable": "편집 자동 수락",
"command.permissions.autoaccept.disable": "편집 자동 수락 중지",
"command.workspace.toggle": "작업 공간 전환",

View File

@@ -72,8 +72,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Bytt til forrige agent",
"command.model.variant.cycle": "Bytt tenkeinnsats",
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
"command.prompt.mode.shell": "Bytt til Shell-modus",
"command.prompt.mode.normal": "Bytt til Prompt-modus",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Godta endringer automatisk",
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
"command.workspace.toggle": "Veksle arbeidsområder",

View File

@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Przełącz na poprzedniego agenta",
"command.model.variant.cycle": "Przełącz wysiłek myślowy",
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
"command.prompt.mode.shell": "Przełącz na tryb terminala",
"command.prompt.mode.normal": "Przełącz na tryb Prompt",
"command.prompt.mode.shell": "Terminal",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
"command.workspace.toggle": "Przełącz przestrzenie robocze",

View File

@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Переключиться к предыдущему агенту",
"command.model.variant.cycle": "Цикл режимов мышления",
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
"command.prompt.mode.shell": "Переключиться в режим оболочки",
"command.prompt.mode.normal": ереключиться в режим промпта",
"command.prompt.mode.shell": "Оболочка",
"command.prompt.mode.normal": "Промпт",
"command.permissions.autoaccept.enable": "Авто-принятие изменений",
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
"command.workspace.toggle": "Переключить рабочие пространства",

View File

@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "สลับไปยังเอเจนต์ก่อนหน้า",
"command.model.variant.cycle": "เปลี่ยนความพยายามในการคิด",
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
"command.prompt.mode.shell": "สลับไปยังโหมดเชลล์",
"command.prompt.mode.normal": "สลับไปยังโหมดพรอมต์",
"command.prompt.mode.shell": "เชลล์",
"command.prompt.mode.normal": "พรอมต์",
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
"command.workspace.toggle": "สลับพื้นที่ทำงาน",

View File

@@ -93,8 +93,8 @@ export const dict = {
"command.model.variant.cycle": "切换思考强度",
"command.model.variant.cycle.description": "切换到下一个强度等级",
"command.prompt.mode.shell": "切换到 Shell 模式",
"command.prompt.mode.normal": "切换到 Prompt 模式",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "自动接受编辑",
"command.permissions.autoaccept.disable": "停止自动接受编辑",

View File

@@ -73,8 +73,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "切換到上一個代理程式",
"command.model.variant.cycle": "循環思考強度",
"command.model.variant.cycle.description": "切換到下一個強度等級",
"command.prompt.mode.shell": "切換到 Shell 模式",
"command.prompt.mode.normal": "切換到 Prompt 模式",
"command.prompt.mode.shell": "Shell",
"command.prompt.mode.normal": "Prompt",
"command.permissions.autoaccept.enable": "自動接受編輯",
"command.permissions.autoaccept.disable": "停止自動接受編輯",
"command.workspace.toggle": "切換工作區",

View File

@@ -177,7 +177,12 @@ export default function Layout(props: ParentProps) {
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
const clearHoverProjectSoon = () => queueMicrotask(() => setState("hoverProject", undefined))
const setHoverProject = (value: string | undefined) => {
setState("hoverProject", value)
if (value !== undefined) return
aim.reset()
}
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const hoverProjectData = createMemo(() => {
@@ -188,13 +193,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
if (!layout.sidebar.opened()) return
aim.reset()
setState("hoverProject", undefined)
})
createEffect(() => {
if (state.hoverProject !== undefined) return
aim.reset()
setHoverProject(undefined)
})
const autoselecting = createMemo(() => {
@@ -225,7 +224,7 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return
setState("hoverSession", undefined)
setState("hoverProject", undefined)
setHoverProject(undefined)
}
const navigateWithSidebarReset = (href: string) => {
@@ -1490,7 +1489,7 @@ export default function Layout(props: ParentProps) {
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
setState("hoverProject", undefined)
setHoverProject(undefined)
setStore("activeProject", id)
}
@@ -1924,7 +1923,7 @@ export default function Layout(props: ParentProps) {
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setState("hoverProject", undefined)
setHoverProject(undefined)
setState("hoverSession", undefined)
}, 300)
}}

View File

@@ -1,4 +1,4 @@
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local"
@@ -981,7 +981,7 @@ export default function Page() {
consumePendingMessage: layout.pendingMessage.consume,
})
createEffect(() => {
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
})

View File

@@ -168,6 +168,13 @@ export function FileTabContent(props: { tab: string }) {
draftTop: undefined as number | undefined,
})
const setCommenting = (range: SelectedLineRange | null) => {
setNote("commenting", range)
scheduleComments()
if (!range) return
setNote("draft", "")
}
const getRoot = () => {
const el = wrap
if (!el) return
@@ -260,13 +267,6 @@ export function FileTabContent(props: { tab: string }) {
scheduleComments()
})
createEffect(() => {
const range = note.commenting
scheduleComments()
if (!range) return
setNote("draft", "")
})
createEffect(() => {
const focus = comments.focus()
const p = path()
@@ -278,7 +278,7 @@ export function FileTabContent(props: { tab: string }) {
if (!target) return
setNote("openedComment", target.id)
setNote("commenting", null)
setCommenting(null)
file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => comments.clearFocus())
})
@@ -438,16 +438,16 @@ export function FileTabContent(props: { tab: string }) {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
if (!range) setNote("commenting", null)
if (!range) setCommenting(null)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
setNote("commenting", null)
setCommenting(null)
return
}
setNote("openedComment", null)
setNote("commenting", range)
setCommenting(range)
}}
overflow="scroll"
class="select-text"
@@ -468,7 +468,7 @@ export function FileTabContent(props: { tab: string }) {
onClick={() => {
const p = path()
if (!p) return
setNote("commenting", null)
setCommenting(null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
@@ -483,12 +483,12 @@ export function FileTabContent(props: { tab: string }) {
value={note.draft}
selection={formatCommentLabel(range())}
onInput={(value) => setNote("draft", value)}
onCancel={() => setNote("commenting", null)}
onCancel={() => setCommenting(null)}
onSubmit={(value) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
setNote("commenting", null)
setCommenting(null)
}}
onPopoverFocusOut={(e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
@@ -497,7 +497,7 @@ export function FileTabContent(props: { tab: string }) {
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null)
setCommenting(null)
}
}, 0)
}}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers"
import { createOpenReviewFile, createOpenSessionFileTab, focusTerminalById, getTabReorderIndex } from "./helpers"
describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => {
@@ -20,6 +20,37 @@ describe("createOpenReviewFile", () => {
})
})
describe("createOpenSessionFileTab", () => {
test("activates the opened file tab", () => {
const calls: string[] = []
const openTab = createOpenSessionFileTab({
normalizeTab: (value) => {
calls.push(`normalize:${value}`)
return `file://${value}`
},
openTab: (tab) => calls.push(`open:${tab}`),
pathFromTab: (tab) => {
calls.push(`path:${tab}`)
return tab.slice("file://".length)
},
loadFile: (path) => calls.push(`load:${path}`),
openReviewPanel: () => calls.push("review"),
setActive: (tab) => calls.push(`active:${tab}`),
})
openTab("src/a.ts")
expect(calls).toEqual([
"normalize:src/a.ts",
"open:file://src/a.ts",
"path:file://src/a.ts",
"load:src/a.ts",
"review",
"active:file://src/a.ts",
])
})
})
describe("focusTerminalById", () => {
test("focuses textarea when present", () => {
document.body.innerHTML = `<div id="terminal-wrapper-one"><div data-component="terminal"><textarea></textarea></div></div>`

View File

@@ -35,6 +35,27 @@ export const createOpenReviewFile = (input: {
}
}
export const createOpenSessionFileTab = (input: {
normalizeTab: (tab: string) => string
openTab: (tab: string) => void
pathFromTab: (tab: string) => string | undefined
loadFile: (path: string) => void
openReviewPanel: () => void
setActive: (tab: string) => void
}) => {
return (value: string) => {
const next = input.normalizeTab(value)
input.openTab(next)
const path = input.pathFromTab(next)
if (!path) return
input.loadFile(path)
input.openReviewPanel()
input.setActive(next)
}
}
export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => {
const fromIndex = tabs.indexOf(from)
const toIndex = tabs.indexOf(to)

View File

@@ -535,7 +535,7 @@ export function MessageTimeline(props: {
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
container: "w-full px-4 md:px-5",
}}
/>
</div>

View File

@@ -144,8 +144,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onOpenChange={props.view().review.setOpen}
classes={{
root: props.classes?.root ?? "pb-6",
header: props.classes?.header ?? "px-6",
container: props.classes?.container ?? "px-6",
header: props.classes?.header ?? "px-3",
container: props.classes?.container ?? "px-3",
}}
diffs={props.diffs()}
diffStyle={props.diffStyle}

View File

@@ -70,29 +70,28 @@ export function SessionPromptDock(props: {
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
const [responding, setResponding] = createSignal(false)
createEffect(
on(
() => permissionRequest()?.id,
() => setResponding(false),
{ defer: true },
),
)
const [responding, setResponding] = createSignal<string | undefined>()
const permissionResponding = () => {
const perm = permissionRequest()
if (!perm) return false
return responding() === perm.id
}
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (responding()) return
if (responding() === perm.id) return
setResponding(true)
setResponding(perm.id)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setResponding(false))
.finally(() => {
setResponding((id) => (id === perm.id ? undefined : id))
})
}
const done = createMemo(
@@ -175,11 +174,11 @@ export function SessionPromptDock(props: {
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
class="shrink-0 w-full pb-4 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
>
<div
classList={{
"w-full px-4 pointer-events-auto": true,
"w-full px-3 pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
@@ -218,18 +217,28 @@ export function SessionPromptDock(props: {
<>
<div />
<div data-slot="permission-footer-actions">
<Button variant="ghost" size="normal" onClick={() => decide("reject")} disabled={responding()}>
<Button
variant="ghost"
size="normal"
onClick={() => decide("reject")}
disabled={permissionResponding()}
>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => decide("always")}
disabled={responding()}
disabled={permissionResponding()}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="normal" onClick={() => decide("once")} disabled={responding()}>
<Button
variant="primary"
size="normal"
onClick={() => decide("once")}
disabled={permissionResponding()}
>
{language.t("ui.permission.allowOnce")}
</Button>
</div>

View File

@@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { getTabReorderIndex } from "@/pages/session/helpers"
import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
import { StickyAddButton } from "@/pages/session/review-tab"
import { setSessionHandoff } from "@/pages/session/handoff"
@@ -96,15 +96,14 @@ export function SessionSidePanel(props: {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = (value: string) => {
const next = normalizeTab(value)
tabs().open(next)
const path = file.pathFromTab(next)
if (!path) return
file.load(path)
openReviewPanel()
}
const openTab = createOpenSessionFileTab({
normalizeTab,
openTab: tabs().open,
pathFromTab: file.pathFromTab,
loadFile: file.load,
openReviewPanel,
setActive: tabs().setActive,
})
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
@@ -355,7 +354,7 @@ export function SessionSidePanel(props: {
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-base px-3 py-0">
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={hasReview()}>
<Show
@@ -384,7 +383,7 @@ export function SessionSidePanel(props: {
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-base px-3 py-0">
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<FileTree
path=""
modified={diffFiles()}

View File

@@ -3136,6 +3136,7 @@ dependencies = [
"tracing-subscriber",
"uuid",
"webkit2gtk",
"windows 0.62.2",
]
[[package]]

View File

@@ -54,6 +54,9 @@ chrono = "0.4"
tokio-stream = { version = "0.1.18", features = ["sync"] }
process-wrap = { version = "9.0.3", features = ["tokio1"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Win32_System_Threading"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.2"

View File

@@ -3,7 +3,7 @@ use process_wrap::tokio::CommandWrap;
#[cfg(unix)]
use process_wrap::tokio::ProcessGroup;
#[cfg(windows)]
use process_wrap::tokio::{JobObject, KillOnDrop};
use process_wrap::tokio::{CommandWrapper, JobObject, KillOnDrop};
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use std::sync::Arc;
@@ -18,9 +18,24 @@ use tokio::{
};
use tokio_stream::wrappers::ReceiverStream;
use tracing::Instrument;
#[cfg(windows)]
use windows::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
use crate::server::get_wsl_config;
#[cfg(windows)]
#[derive(Clone, Copy, Debug)]
// Keep this as a custom wrapper instead of process_wrap::CreationFlags.
// JobObject pre_spawn rewrites creation flags, so this must run after it.
struct WinCreationFlags;
#[cfg(windows)]
impl CommandWrapper for WinCreationFlags {
fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> {
command.creation_flags((CREATE_NO_WINDOW | CREATE_SUSPENDED).0);
Ok(())
}
}
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
@@ -203,7 +218,7 @@ fn get_user_shell() -> String {
}
fn is_wsl_enabled(_app: &tauri::AppHandle) -> bool {
get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
}
fn shell_escape(input: &str) -> String {
@@ -318,9 +333,6 @@ pub fn spawn_command(
cmd.stderr(Stdio::piped());
cmd.stdin(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
let mut wrap = CommandWrap::from(cmd);
#[cfg(unix)]
@@ -330,7 +342,7 @@ pub fn spawn_command(
#[cfg(windows)]
{
wrap.wrap(JobObject).wrap(KillOnDrop);
wrap.wrap(JobObject).wrap(WinCreationFlags).wrap(KillOnDrop);
}
let mut child = wrap.spawn()?;

View File

@@ -30,6 +30,7 @@ import {
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { Filesystem } from "../util/filesystem"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -228,8 +229,7 @@ export namespace ACP {
const metadata = permission.metadata || {}
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
const file = Bun.file(filepath)
const content = (await file.exists()) ? await file.text() : ""
const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : ""
const newContent = getNewContent(content, diff)
if (newContent) {

View File

@@ -1,6 +1,7 @@
import path from "path"
import { Global } from "../global"
import z from "zod"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
@@ -42,8 +43,7 @@ export namespace Auth {
}
export async function all(): Promise<Record<string, Info>> {
const file = Bun.file(filepath)
const data = await file.json().catch(() => ({}) as Record<string, unknown>)
const data = await Filesystem.readJson<Record<string, unknown>>(filepath).catch(() => ({}))
return Object.entries(data).reduce(
(acc, [key, value]) => {
const parsed = Info.safeParse(value)
@@ -56,15 +56,13 @@ export namespace Auth {
}
export async function set(key: string, info: Info) {
const file = Bun.file(filepath)
const data = await all()
await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2), { mode: 0o600 })
await Filesystem.writeJson(filepath, { ...data, [key]: info }, 0o600)
}
export async function remove(key: string) {
const file = Bun.file(filepath)
const data = await all()
delete data[key]
await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 })
await Filesystem.writeJson(filepath, data, 0o600)
}
}

View File

@@ -6,6 +6,7 @@ import { Agent } from "../../agent/agent"
import { Provider } from "../../provider/provider"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../util/filesystem"
import matter from "gray-matter"
import { Instance } from "../../project/instance"
import { EOL } from "os"
@@ -202,8 +203,7 @@ const AgentCreateCommand = cmd({
await fs.mkdir(targetPath, { recursive: true })
const file = Bun.file(filePath)
if (await file.exists()) {
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
@@ -212,7 +212,7 @@ const AgentCreateCommand = cmd({
throw new UI.CancelledError()
}
await Bun.write(filePath, content)
await Filesystem.write(filePath, content)
if (isFullyNonInteractive) {
console.log(filePath)

View File

@@ -8,6 +8,7 @@ import { SessionTable, MessageTable, PartTable } from "../../session/session.sql
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
export type ShareData =
@@ -116,8 +117,7 @@ export const ImportCommand = cmd({
exportData = transformed
} else {
const file = Bun.file(args.file)
exportData = await file.json().catch(() => {})
exportData = await Filesystem.readJson<NonNullable<typeof exportData>>(args.file).catch(() => undefined)
if (!exportData) {
process.stdout.write(`File not found: ${args.file}`)
process.stdout.write(EOL)

View File

@@ -168,12 +168,17 @@ function websearch(info: ToolProps<typeof WebSearchTool>) {
}
function task(info: ToolProps<typeof TaskTool>) {
const agent = Locale.titlecase(info.input.subagent_type)
const desc = info.input.description
const started = info.part.state.status === "running"
const input = info.part.state.input
const status = info.part.state.status
const subagent =
typeof input.subagent_type === "string" && input.subagent_type.trim().length > 0 ? input.subagent_type : "unknown"
const agent = Locale.titlecase(subagent)
const desc =
typeof input.description === "string" && input.description.trim().length > 0 ? input.description : undefined
const icon = status === "error" ? "✗" : status === "running" ? "•" : "✓"
const name = desc ?? `${agent} Task`
inline({
icon: started ? "•" : "✓",
icon,
title: name,
description: desc ? `${agent} Agent` : undefined,
})
@@ -451,9 +456,17 @@ export const RunCommand = cmd({
const part = event.properties.part
if (part.sessionID !== sessionID) continue
if (part.type === "tool" && part.state.status === "completed") {
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
if (emit("tool_use", { part })) continue
tool(part)
if (part.state.status === "completed") {
tool(part)
continue
}
inline({
icon: "✗",
title: `${part.tool} failed`,
})
UI.error(part.state.error)
}
if (

View File

@@ -5,6 +5,7 @@ import { bootstrap } from "../bootstrap"
import { UI } from "../ui"
import { Locale } from "../../util/locale"
import { Flag } from "../../flag/flag"
import { Filesystem } from "../../util/filesystem"
import { EOL } from "os"
import path from "path"
@@ -17,18 +18,18 @@ function pagerCmd(): string[] {
// user could have less installed via other options
const lessOnPath = Bun.which("less")
if (lessOnPath) {
if (Bun.file(lessOnPath).size) return [lessOnPath, ...lessOptions]
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
}
if (Flag.OPENCODE_GIT_BASH_PATH) {
const less = path.join(Flag.OPENCODE_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe")
if (Bun.file(less).size) return [less, ...lessOptions]
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
}
const git = Bun.which("git")
if (git) {
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
if (Bun.file(less).size) return [less, ...lessOptions]
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
}
// Fall back to Windows built-in more (via cmd.exe)

View File

@@ -34,7 +34,6 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
@@ -43,7 +42,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
process.exit(0)
await input.onExit?.()
},
{
message: store,

View File

@@ -104,6 +104,9 @@ export namespace UI {
}
export function error(message: string) {
if (message.startsWith("Error: ")) {
message = message.slice("Error: ".length)
}
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}

View File

@@ -1,6 +1,7 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Filesystem } from "../util/filesystem"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
@@ -59,10 +60,10 @@ export namespace FileTime {
const time = get(sessionID, filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const stats = await Bun.file(filepath).stat()
if (stats.mtime.getTime() > time.getTime()) {
const mtime = Filesystem.stat(filepath)?.mtime
if (mtime && mtime.getTime() > time.getTime()) {
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${stats.mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
)
}
}

View File

@@ -18,18 +18,24 @@ export namespace Pty {
type Socket = {
readyState: number
data: object
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const sockets = new WeakMap<object, number>()
let socketCounter = 0
// Bun's ServerWebSocket has a per-connection `.data` object (set during
// `server.upgrade`) that changes when the underlying connection is recycled.
// We keep a reference to a stable part of it so output can't leak even when
// websocket objects are reused.
const token = (ws: Socket) => {
const data = ws.data
const events = (data as { events?: unknown }).events
if (events && typeof events === "object") return events
const tagSocket = (ws: Socket) => {
if (!ws || typeof ws !== "object") return
const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER)
sockets.set(ws, next)
return next
const url = (data as { url?: unknown }).url
if (url && typeof url === "object") return url
return data
}
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
@@ -96,7 +102,7 @@ export namespace Pty {
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<Socket, number>
subscribers: Map<Socket, object>
}
const state = Instance.state(
@@ -176,26 +182,27 @@ export namespace Pty {
subscribers: new Map(),
}
state().set(id, session)
ptyProcess.onData((data) => {
session.cursor += data.length
ptyProcess.onData((chunk) => {
session.cursor += chunk.length
for (const [ws, id] of session.subscribers) {
for (const [ws, data] of session.subscribers) {
if (ws.readyState !== 1) {
session.subscribers.delete(ws)
continue
}
if (typeof ws === "object" && sockets.get(ws) !== id) {
if (token(ws) !== data) {
session.subscribers.delete(ws)
continue
}
try {
ws.send(data)
ws.send(chunk)
} catch {
session.subscribers.delete(ws)
}
}
session.buffer += data
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
@@ -305,8 +312,12 @@ export namespace Pty {
return
}
const socketId = tagSocket(ws)
if (typeof socketId === "number") session.subscribers.set(ws, socketId)
if (!ws.data || typeof ws.data !== "object") {
ws.close()
return
}
session.subscribers.set(ws, token(ws))
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))

View File

@@ -163,6 +163,7 @@ export const PtyRoutes = lazy(() =>
type Socket = {
readyState: number
data: object
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
@@ -170,6 +171,10 @@ export const PtyRoutes = lazy(() =>
const isSocket = (value: unknown): value is Socket => {
if (!value || typeof value !== "object") return false
if (!("readyState" in value)) return false
if (!("data" in value)) return false
if (!((value as { data?: unknown }).data && typeof (value as { data?: unknown }).data === "object")) {
return false
}
if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
return typeof (value as { readyState?: unknown }).readyState === "number"
@@ -177,11 +182,16 @@ export const PtyRoutes = lazy(() =>
return {
onOpen(_event, ws) {
const socket = isSocket(ws.raw) ? ws.raw : ws
handler = Pty.connect(id, socket, cursor)
const raw = ws.raw
if (!isSocket(raw)) {
ws.close()
return
}
handler = Pty.connect(id, raw, cursor)
},
onMessage(event) {
handler?.onMessage(String(event.data))
if (typeof event.data !== "string") return
handler?.onMessage(event.data)
},
onClose() {
handler?.onClose()

View File

@@ -1618,7 +1618,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const args = matchingInvocation?.args
const cwd = Instance.directory
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const shellEnv = await Plugin.trigger(
"shell.env",
{ cwd, sessionID: input.sessionID, callID: part.callID },
{ env: {} },
)
const proc = spawn(shell, args, {
cwd,
detached: process.platform !== "win32",

View File

@@ -163,7 +163,11 @@ export const BashTool = Tool.define("bash", async () => {
})
}
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const shellEnv = await Plugin.trigger(
"shell.env",
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
{ env: {} },
)
const proc = spawn(params.command, {
shell,
cwd,

View File

@@ -18,12 +18,13 @@ export namespace Filesystem {
}
}
export function stat(p: string): ReturnType<typeof statSync> | undefined {
return statSync(p, { throwIfNoEntry: false }) ?? undefined
}
export async function size(p: string): Promise<number> {
try {
return statSync(p).size
} catch {
return 0
}
const s = stat(p)?.size ?? 0
return typeof s === "bigint" ? Number(s) : s
}
export async function readText(p: string): Promise<string> {

View File

@@ -18,6 +18,7 @@ describe("pty", () => {
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
@@ -30,6 +31,7 @@ describe("pty", () => {
Pty.connect(a.id, ws as any)
// Now "reuse" the same ws object for another connection.
ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
@@ -51,4 +53,48 @@ describe("pty", () => {
},
})
})
test("does not leak output when Bun recycles websocket objects before re-connect", async () => {
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
try {
const outA: string[] = []
const outB: string[] = []
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
},
}
// Connect "a" first.
Pty.connect(a.id, ws as any)
outA.length = 0
// Simulate Bun reusing the same websocket object for another connection
// before the new onOpen handler has a chance to tag it.
ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
await Pty.remove(a.id)
}
},
})
})
})

View File

@@ -185,7 +185,10 @@ export interface Hooks {
input: { tool: string; sessionID: string; callID: string },
output: { args: any },
) => Promise<void>
"shell.env"?: (input: { cwd: string }, output: { env: Record<string, string> }) => Promise<void>
"shell.env"?: (
input: { cwd: string; sessionID?: string; callID?: string },
output: { env: Record<string, string> },
) => Promise<void>
"tool.execute.after"?: (
input: { tool: string; sessionID: string; callID: string; args: any },
output: {

View File

@@ -6,15 +6,24 @@ import { $ } from "bun"
const dir = new URL("..", import.meta.url).pathname
process.chdir(dir)
const pkg = await import("../package.json").then((m) => m.default)
const pkg = (await import("../package.json").then((m) => m.default)) as {
exports: Record<string, string | object>
}
const original = JSON.parse(JSON.stringify(pkg))
for (const [key, value] of Object.entries(pkg.exports)) {
const file = value.replace("./src/", "./dist/").replace(".ts", "")
pkg.exports[key] = {
import: file + ".js",
types: file + ".d.ts",
function transformExports(exports: Record<string, string | object>) {
for (const [key, value] of Object.entries(exports)) {
if (typeof value === "object" && value !== null) {
transformExports(value as Record<string, string | object>)
} else if (typeof value === "string") {
const file = value.replace("./src/", "./dist/").replace(".ts", "")
exports[key] = {
import: file + ".js",
types: file + ".d.ts",
}
}
}
}
transformExports(pkg.exports)
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
await $`bun pm pack`
await $`npm publish *.tgz --tag ${Script.channel} --access public`

View File

@@ -48,13 +48,13 @@
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
}
&:focus-visible:not(:disabled) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
}
&:active:not(:disabled) {
background-color: var(--surface-raised-base-active);
background-color: var(--surface-base-active);
}
&:disabled {
color: var(--text-weak);
@@ -65,10 +65,10 @@
}
}
&[data-selected="true"]:not(:disabled) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
}
&[data-active="true"] {
background-color: var(--surface-raised-base-active);
background-color: var(--surface-base-active);
}
}
@@ -76,7 +76,7 @@
border: transparent;
background-color: var(--button-secondary-base);
color: var(--text-strong);
box-shadow: var(--shadow-xs-border);
box-shadow: var(--shadow-xs-border-base);
&:hover:not(:disabled) {
background-color: var(--button-secondary-hover);
@@ -93,8 +93,6 @@
}
&:active:not(:disabled) {
background-color: var(--button-secondary-base);
scale: 0.99;
transition: all 150ms ease-out;
}
&:disabled {
border-color: var(--border-disabled);
@@ -104,7 +102,7 @@
}
[data-slot="icon-svg"] {
color: var(--icon-strong-base);
color: var(--icon-base);
}
}
@@ -171,10 +169,6 @@
}
}
[data-component="button"].titlebar-icon[data-variant="ghost"]:hover:not(:disabled) {
background-color: var(--surface-raised-base-active);
}
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] {
background-color: var(--surface-base-active);
}

View File

@@ -29,11 +29,10 @@
}
[data-slot="collapsible-arrow-icon"] {
display: none;
}
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
display: inline-flex;
color: var(--icon-weaker);
transform: rotate(-90deg);
transition: transform 0.15s ease;
}
&:hover [data-slot="collapsible-arrow"] {
@@ -74,12 +73,8 @@
opacity: 1;
}
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
display: none;
}
[data-slot="collapsible-arrow-icon"][data-direction="down"] {
display: inline-flex;
[data-slot="collapsible-arrow-icon"] {
transform: rotate(0deg);
}
}

View File

@@ -34,10 +34,7 @@ function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
function CollapsibleArrow(props?: ComponentProps<"div">) {
return (
<div data-slot="collapsible-arrow" {...(props || {})}>
<span data-slot="collapsible-arrow-icon" data-direction="right">
<Icon name="chevron-right" size="small" />
</span>
<span data-slot="collapsible-arrow-icon" data-direction="down">
<span data-slot="collapsible-arrow-icon">
<Icon name="chevron-down" size="small" />
</span>
</div>

View File

@@ -45,7 +45,7 @@
border: transparent;
background-color: var(--button-secondary-base);
color: var(--text-strong);
box-shadow: var(--shadow-xs-border);
box-shadow: var(--shadow-xs-border-base);
&:hover:not(:disabled) {
background-color: var(--button-secondary-hover);
@@ -84,23 +84,23 @@
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
/* [data-slot="icon-svg"] { */
/* color: var(--icon-hover); */
/* } */
}
&:focus-visible:not(:disabled) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
}
&:active:not(:disabled) {
background-color: var(--surface-raised-base-active);
background-color: var(--surface-base-active);
/* [data-slot="icon-svg"] { */
/* color: var(--icon-active); */
/* } */
}
&:selected:not(:disabled) {
background-color: var(--surface-raised-base-active);
background-color: var(--surface-base-active);
/* [data-slot="icon-svg"] { */
/* color: var(--icon-selected); */
/* } */
@@ -168,12 +168,8 @@
aspect-ratio: auto;
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"]:hover:not(:disabled) {
background-color: var(--surface-raised-base-active);
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] {
background-color: var(--surface-base-active);
background-color: var(--surface-raised-base-active);
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] [data-slot="icon-svg"] {
@@ -181,5 +177,5 @@
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
background-color: var(--surface-base-active);
background-color: var(--surface-raised-base-active);
}

View File

@@ -50,6 +50,7 @@ const icons = {
"layout-right-partial": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
"layout-right-full": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
"open-file": `<path d="M7.91602 2.91406H2.91602V17.0807H17.0827V12.0807M12.0827 2.91406H17.0827V7.91406M9.58268 10.4141L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
comment: `<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square"/>`,
"folder-add-left": `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,

View File

@@ -227,12 +227,13 @@
[data-component="reasoning-part"] {
width: 100%;
color: var(--text-base);
opacity: 0.8;
font-size: var(--font-size-small);
line-height: var(--line-height-large);
[data-component="markdown"] {
margin-top: 24px;
font-style: normal;
font-size: inherit;
p:has(strong) {
margin-top: 24px;

View File

@@ -38,9 +38,9 @@
}
[data-slot="radio-group-indicator"] {
background: var(--surface-raised-stronger-non-alpha);
background: var(--button-secondary-base);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-xs-border);
box-shadow: var(--shadow-xs-border-base);
content: "";
opacity: var(--indicator-opacity, 1);
pointer-events: none;
@@ -80,7 +80,7 @@
[data-slot="radio-group-item-label"] {
color: var(--text-weak);
cursor: pointer;
cursor: default;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -38,10 +38,24 @@
[data-slot="session-review-actions"] {
display: flex;
align-items: center;
column-gap: 16px;
column-gap: 12px;
padding-right: 1px;
}
[data-slot="session-review-actions"] [data-component="radio-group"] {
[data-slot="radio-group-wrapper"],
[data-slot="radio-group-indicator"],
[data-slot="radio-group-item-control"] {
border-radius: 6px;
}
[data-slot="radio-group-item-input"]:not([data-checked], [data-disabled])
+ [data-slot="radio-group-item-label"]:hover
[data-slot="radio-group-item-control"] {
border-radius: 4px;
}
}
[data-component="sticky-accordion-header"] {
top: 40px;
}
@@ -51,14 +65,61 @@
top: -40px;
}
[data-slot="accordion-trigger"] {
background-color: var(--background-stronger) !important;
}
[data-slot="session-review-diffs-group"] {
background-color: var(--background-stronger);
border-radius: var(--radius-lg);
border: 1px solid var(--border-weak-base);
overflow: clip;
[data-slot="session-review-accordion-item"][data-selected] {
[data-slot="session-review-accordion-content"] {
box-shadow: var(--shadow-xs-border-select);
border-radius: var(--radius-lg);
[data-component="accordion"] {
gap: 0;
}
[data-component="accordion"] [data-slot="accordion-item"] {
overflow: visible;
}
[data-component="accordion"]
[data-slot="accordion-item"]
[data-slot="accordion-header"]
[data-slot="accordion-trigger"] {
border: 0;
border-radius: 0;
box-shadow: none;
background-color: transparent;
&:hover {
background-color: var(--surface-base-hover);
}
&:active {
background-color: var(--surface-base-active);
}
}
[data-component="accordion"]
[data-slot="accordion-item"]
+ [data-slot="accordion-item"]
[data-slot="accordion-header"]
[data-slot="accordion-trigger"] {
border-top: 1px solid var(--border-weak-base);
}
[data-component="accordion"] [data-slot="accordion-item"][data-expanded] [data-slot="accordion-content"] {
border: 0;
border-top: 1px solid var(--border-weak-base);
border-radius: 0;
}
[data-component="sticky-accordion-header"][data-expanded]::before,
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
top: 0;
}
[data-slot="session-review-accordion-item"][data-selected]
[data-slot="accordion-header"]
[data-slot="accordion-trigger"] {
background-color: var(--surface-base-active);
}
}
@@ -96,12 +157,13 @@
flex-grow: 1;
display: flex;
align-items: center;
gap: 20px;
gap: 12px;
min-width: 0;
}
[data-slot="session-review-file-name-container"] {
display: flex;
align-items: center;
flex-grow: 1;
min-width: 0;
}
@@ -132,6 +194,8 @@
cursor: pointer;
border-radius: 4px;
opacity: 0;
will-change: opacity;
transform: translateZ(0);
transition: opacity 0.15s ease;
&:hover {
@@ -152,6 +216,23 @@
justify-content: flex-end;
}
[data-slot="session-review-diff-chevron"] {
display: inline-flex;
color: var(--icon-weaker);
transform: rotate(-90deg);
transition: transform 0.15s ease;
}
[data-slot="accordion-item"][data-expanded] [data-slot="session-review-diff-chevron"] {
transform: rotate(0deg);
}
[data-slot="session-review-change-group"] {
display: inline-flex;
align-items: center;
gap: 12px;
}
[data-slot="session-review-change"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);

View File

@@ -6,6 +6,7 @@ import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { LineComment, LineCommentEditor } from "./line-comment"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Tooltip } from "./tooltip"
import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -286,18 +287,14 @@ export const SessionReview = (props: SessionReviewProps) => {
[props.class ?? ""]: !!props.class,
}}
>
<div
data-slot="session-review-header"
classList={{
[props.classes?.header ?? ""]: !!props.classes?.header,
}}
>
<div data-slot="session-review-header" class={props.classes?.header}>
<div data-slot="session-review-title">{props.title ?? i18n.t("ui.sessionReview.title")}</div>
<div data-slot="session-review-actions">
<Show when={hasDiffs() && props.onDiffStyleChange}>
<RadioGroup
options={["unified", "split"] as const}
current={diffStyle()}
size="small"
value={(style) => style}
label={(style) =>
i18n.t(style === "unified" ? "ui.sessionReview.diffStyle.unified" : "ui.sessionReview.diffStyle.split")
@@ -306,7 +303,12 @@ export const SessionReview = (props: SessionReviewProps) => {
/>
</Show>
<Show when={hasDiffs()}>
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
<Button
size="small"
icon="chevron-grabber-vertical"
class="w-[106px] justify-start"
onClick={handleExpandOrCollapseAll}
>
<Switch>
<Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
<Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
@@ -316,392 +318,397 @@ export const SessionReview = (props: SessionReviewProps) => {
{props.actions}
</div>
</div>
<div
data-slot="session-review-container"
classList={{
[props.classes?.container ?? ""]: !!props.classes?.container,
}}
>
<div data-slot="session-review-container" class={props.classes?.container}>
<Show when={hasDiffs()} fallback={props.empty}>
<Accordion multiple value={open()} onChange={handleChange}>
<For each={props.diffs}>
{(diff) => {
let wrapper: HTMLDivElement | undefined
<div data-slot="session-review-diffs-group">
<Accordion multiple value={open()} onChange={handleChange}>
<For each={props.diffs}>
{(diff) => {
let wrapper: HTMLDivElement | undefined
const expanded = createMemo(() => open().includes(diff.file))
const [force, setForce] = createSignal(false)
const expanded = createMemo(() => open().includes(diff.file))
const [force, setForce] = createSignal(false)
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
const commentedLines = createMemo(() => comments().map((c) => c.selection))
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
const changedLines = () => diff.additions + diff.deletions
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
const changedLines = () => diff.additions + diff.deletions
const tooLarge = createMemo(() => {
if (!expanded()) return false
if (force()) return false
if (isImageFile(diff.file)) return false
return changedLines() > MAX_DIFF_CHANGED_LINES
})
const tooLarge = createMemo(() => {
if (!expanded()) return false
if (force()) return false
if (isImageFile(diff.file)) return false
return changedLines() > MAX_DIFF_CHANGED_LINES
})
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
const isImage = () => isImageFile(diff.file)
const isAudio = () => isAudioFile(diff.file)
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
const isImage = () => isImageFile(diff.file)
const isAudio = () => isAudioFile(diff.file)
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
const selectedLines = createMemo(() => {
const current = selection()
if (!current || current.file !== diff.file) return null
return current.range
})
const selectedLines = createMemo(() => {
const current = selection()
if (!current || current.file !== diff.file) return null
return current.range
})
const draftRange = createMemo(() => {
const current = commenting()
if (!current || current.file !== diff.file) return null
return current.range
})
const draftRange = createMemo(() => {
const current = commenting()
if (!current || current.file !== diff.file) return null
return current.range
})
const [draft, setDraft] = createSignal("")
const [positions, setPositions] = createSignal<Record<string, number>>({})
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
const [draft, setDraft] = createSignal("")
const [positions, setPositions] = createSignal<Record<string, number>>({})
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
const getRoot = () => {
const el = wrapper
if (!el) return
const getRoot = () => {
const el = wrapper
if (!el) return
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
return host.shadowRoot ?? undefined
}
const updateAnchors = () => {
const el = wrapper
if (!el) return
const root = getRoot()
if (!root) return
const next: Record<string, number> = {}
for (const item of comments()) {
const marker = findMarker(root, item.selection)
if (!marker) continue
next[item.id] = markerTop(el, marker)
}
setPositions(next)
const range = draftRange()
if (!range) {
setDraftTop(undefined)
return
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
return host.shadowRoot ?? undefined
}
const marker = findMarker(root, range)
if (!marker) {
setDraftTop(undefined)
return
const updateAnchors = () => {
const el = wrapper
if (!el) return
const root = getRoot()
if (!root) return
const next: Record<string, number> = {}
for (const item of comments()) {
const marker = findMarker(root, item.selection)
if (!marker) continue
next[item.id] = markerTop(el, marker)
}
setPositions(next)
const range = draftRange()
if (!range) {
setDraftTop(undefined)
return
}
const marker = findMarker(root, range)
if (!marker) {
setDraftTop(undefined)
return
}
setDraftTop(markerTop(el, marker))
}
setDraftTop(markerTop(el, marker))
}
const scheduleAnchors = () => {
requestAnimationFrame(updateAnchors)
}
const scheduleAnchors = () => {
requestAnimationFrame(updateAnchors)
}
createEffect(() => {
comments()
scheduleAnchors()
})
createEffect(() => {
comments()
scheduleAnchors()
})
createEffect(() => {
const range = draftRange()
if (!range) return
setDraft("")
scheduleAnchors()
})
createEffect(() => {
const range = draftRange()
if (!range) return
setDraft("")
scheduleAnchors()
})
createEffect(() => {
if (!open().includes(diff.file)) return
if (!isImage()) return
if (imageSrc()) return
if (imageStatus() !== "idle") return
if (isDeleted()) return
createEffect(() => {
if (!open().includes(diff.file)) return
if (!isImage()) return
if (imageSrc()) return
if (imageStatus() !== "idle") return
if (isDeleted()) return
const reader = props.readFile
if (!reader) return
const reader = props.readFile
if (!reader) return
setImageStatus("loading")
reader(diff.file)
.then((result) => {
const src = dataUrl(result)
if (!src) {
setImageStatus("loading")
reader(diff.file)
.then((result) => {
const src = dataUrl(result)
if (!src) {
setImageStatus("error")
return
}
setImageSrc(src)
setImageStatus("idle")
})
.catch(() => {
setImageStatus("error")
return
}
setImageSrc(src)
setImageStatus("idle")
})
.catch(() => {
setImageStatus("error")
})
})
})
})
createEffect(() => {
if (!open().includes(diff.file)) return
if (!isAudio()) return
if (audioSrc()) return
if (audioStatus() !== "idle") return
createEffect(() => {
if (!open().includes(diff.file)) return
if (!isAudio()) return
if (audioSrc()) return
if (audioStatus() !== "idle") return
const reader = props.readFile
if (!reader) return
const reader = props.readFile
if (!reader) return
setAudioStatus("loading")
reader(diff.file)
.then((result) => {
const src = dataUrl(result)
if (!src) {
setAudioStatus("loading")
reader(diff.file)
.then((result) => {
const src = dataUrl(result)
if (!src) {
setAudioStatus("error")
return
}
setAudioMime(normalizeMimeType(result?.mimeType))
setAudioSrc(src)
setAudioStatus("idle")
})
.catch(() => {
setAudioStatus("error")
return
}
setAudioMime(normalizeMimeType(result?.mimeType))
setAudioSrc(src)
setAudioStatus("idle")
})
.catch(() => {
setAudioStatus("error")
})
})
})
})
const handleLineSelected = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
const handleLineSelected = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
if (!range) {
setSelection(null)
return
if (!range) {
setSelection(null)
return
}
setSelection({ file: diff.file, range })
}
setSelection({ file: diff.file, range })
}
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
if (!range) {
setCommenting(null)
return
}
if (!range) {
setCommenting(null)
return
setSelection({ file: diff.file, range })
setCommenting({ file: diff.file, range })
}
setSelection({ file: diff.file, range })
setCommenting({ file: diff.file, range })
}
const openComment = (comment: SessionReviewComment) => {
setOpened({ file: comment.file, id: comment.id })
setSelection({ file: comment.file, range: comment.selection })
}
const openComment = (comment: SessionReviewComment) => {
setOpened({ file: comment.file, id: comment.id })
setSelection({ file: comment.file, range: comment.selection })
}
const isCommentOpen = (comment: SessionReviewComment) => {
const current = opened()
if (!current) return false
return current.file === comment.file && current.id === comment.id
}
const isCommentOpen = (comment: SessionReviewComment) => {
const current = opened()
if (!current) return false
return current.file === comment.file && current.id === comment.id
}
return (
<Accordion.Item
value={diff.file}
id={diffId(diff.file)}
data-file={diff.file}
data-slot="session-review-accordion-item"
data-selected={props.focusedFile === diff.file ? "" : undefined}
>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
<div data-slot="session-review-file-info">
<FileIcon node={{ path: diff.file, type: "file" }} />
<div data-slot="session-review-file-name-container">
<Show when={diff.file.includes("/")}>
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
</Show>
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
<Show when={props.onViewFile}>
<button
data-slot="session-review-view-button"
type="button"
onClick={(e) => {
e.stopPropagation()
props.onViewFile?.(diff.file)
}}
>
<Icon name="eye" size="small" />
</button>
</Show>
return (
<Accordion.Item
value={diff.file}
id={diffId(diff.file)}
data-file={diff.file}
data-slot="session-review-accordion-item"
data-selected={props.focusedFile === diff.file ? "" : undefined}
>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
<div data-slot="session-review-file-info">
<FileIcon node={{ path: diff.file, type: "file" }} />
<div data-slot="session-review-file-name-container">
<Show when={diff.file.includes("/")}>
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
</Show>
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
<Show when={props.onViewFile}>
<Tooltip value="Open file" placement="top" gutter={4}>
<button
data-slot="session-review-view-button"
type="button"
aria-label="Open file"
onClick={(e) => {
e.stopPropagation()
props.onViewFile?.(diff.file)
}}
>
<Icon name="open-file" size="small" />
</button>
</Tooltip>
</Show>
</div>
</div>
<div data-slot="session-review-trigger-actions">
<Switch>
<Match when={isAdded()}>
<div data-slot="session-review-change-group" data-type="added">
<span data-slot="session-review-change" data-type="added">
{i18n.t("ui.sessionReview.change.added")}
</span>
<DiffChanges changes={diff} />
</div>
</Match>
<Match when={isDeleted()}>
<span data-slot="session-review-change" data-type="removed">
{i18n.t("ui.sessionReview.change.removed")}
</span>
</Match>
<Match when={isImage()}>
<span data-slot="session-review-change" data-type="modified">
{i18n.t("ui.sessionReview.change.modified")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={diff} />
</Match>
</Switch>
<span data-slot="session-review-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
<div data-slot="session-review-trigger-actions">
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-review-accordion-content">
<div
data-slot="session-review-diff-wrapper"
ref={(el) => {
wrapper = el
anchors.set(diff.file, el)
scheduleAnchors()
}}
>
<Show when={expanded()}>
<Switch>
<Match when={isAdded()}>
<span data-slot="session-review-change" data-type="added">
{i18n.t("ui.sessionReview.change.added")}
</span>
</Match>
<Match when={isDeleted()}>
<span data-slot="session-review-change" data-type="removed">
{i18n.t("ui.sessionReview.change.removed")}
</span>
</Match>
<Match when={isImage()}>
<span data-slot="session-review-change" data-type="modified">
{i18n.t("ui.sessionReview.change.modified")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={diff} />
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-review-accordion-content">
<div
data-slot="session-review-diff-wrapper"
ref={(el) => {
wrapper = el
anchors.set(diff.file, el)
scheduleAnchors()
}}
>
<Show when={expanded()}>
<Switch>
<Match when={isImage() && imageSrc()}>
<div data-slot="session-review-image-container">
<img data-slot="session-review-image" src={imageSrc()} alt={diff.file} />
</div>
</Match>
<Match when={isImage() && isDeleted()}>
<div data-slot="session-review-image-container" data-removed>
<span data-slot="session-review-image-placeholder">
{i18n.t("ui.sessionReview.change.removed")}
</span>
</div>
</Match>
<Match when={isImage() && !imageSrc()}>
<div data-slot="session-review-image-container">
<span data-slot="session-review-image-placeholder">
{imageStatus() === "loading"
? i18n.t("ui.sessionReview.image.loading")
: i18n.t("ui.sessionReview.image.placeholder")}
</span>
</div>
</Match>
<Match when={!isImage() && tooLarge()}>
<div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title">
{i18n.t("ui.sessionReview.largeDiff.title")}
<Match when={isImage() && imageSrc()}>
<div data-slot="session-review-image-container">
<img data-slot="session-review-image" src={imageSrc()} alt={diff.file} />
</div>
<div data-slot="session-review-large-diff-meta">
{i18n.t("ui.sessionReview.largeDiff.meta", {
limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
current: changedLines().toLocaleString(),
})}
</Match>
<Match when={isImage() && isDeleted()}>
<div data-slot="session-review-image-container" data-removed>
<span data-slot="session-review-image-placeholder">
{i18n.t("ui.sessionReview.change.removed")}
</span>
</div>
<div data-slot="session-review-large-diff-actions">
<Button size="normal" variant="secondary" onClick={() => setForce(true)}>
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
</Button>
</Match>
<Match when={isImage() && !imageSrc()}>
<div data-slot="session-review-image-container">
<span data-slot="session-review-image-placeholder">
{imageStatus() === "loading"
? i18n.t("ui.sessionReview.image.loading")
: i18n.t("ui.sessionReview.image.placeholder")}
</span>
</div>
</div>
</Match>
<Match when={!isImage()}>
<Dynamic
component={diffComponent}
preloadedDiff={diff.preloaded}
diffStyle={diffStyle()}
onRendered={() => {
props.onDiffRendered?.()
scheduleAnchors()
}}
enableLineSelection={props.onLineComment != null}
onLineSelected={handleLineSelected}
onLineSelectionEnd={handleLineSelectionEnd}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
before={{
name: diff.file!,
contents: typeof diff.before === "string" ? diff.before : "",
}}
after={{
name: diff.file!,
contents: typeof diff.after === "string" ? diff.after : "",
}}
/>
</Match>
</Switch>
<For each={comments()}>
{(comment) => (
<LineComment
id={comment.id}
top={positions()[comment.id]}
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
onClick={() => {
if (isCommentOpen(comment)) {
setOpened(null)
return
}
openComment(comment)
}}
open={isCommentOpen(comment)}
comment={comment.comment}
selection={selectionLabel(comment.selection)}
/>
)}
</For>
<Show when={draftRange()}>
{(range) => (
<Show when={draftTop() !== undefined}>
<LineCommentEditor
top={draftTop()}
value={draft()}
selection={selectionLabel(range())}
onInput={setDraft}
onCancel={() => setCommenting(null)}
onSubmit={(comment) => {
props.onLineComment?.({
file: diff.file,
selection: range(),
comment,
preview: selectionPreview(diff, range()),
})
setCommenting(null)
</Match>
<Match when={!isImage() && tooLarge()}>
<div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title">
{i18n.t("ui.sessionReview.largeDiff.title")}
</div>
<div data-slot="session-review-large-diff-meta">
{i18n.t("ui.sessionReview.largeDiff.meta", {
limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
current: changedLines().toLocaleString(),
})}
</div>
<div data-slot="session-review-large-diff-actions">
<Button size="normal" variant="secondary" onClick={() => setForce(true)}>
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
</Button>
</div>
</div>
</Match>
<Match when={!isImage()}>
<Dynamic
component={diffComponent}
preloadedDiff={diff.preloaded}
diffStyle={diffStyle()}
onRendered={() => {
props.onDiffRendered?.()
scheduleAnchors()
}}
enableLineSelection={props.onLineComment != null}
onLineSelected={handleLineSelected}
onLineSelectionEnd={handleLineSelectionEnd}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
before={{
name: diff.file!,
contents: typeof diff.before === "string" ? diff.before : "",
}}
after={{
name: diff.file!,
contents: typeof diff.after === "string" ? diff.after : "",
}}
/>
</Show>
)}
</Match>
</Switch>
<For each={comments()}>
{(comment) => (
<LineComment
id={comment.id}
top={positions()[comment.id]}
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
onClick={() => {
if (isCommentOpen(comment)) {
setOpened(null)
return
}
openComment(comment)
}}
open={isCommentOpen(comment)}
comment={comment.comment}
selection={selectionLabel(comment.selection)}
/>
)}
</For>
<Show when={draftRange()}>
{(range) => (
<Show when={draftTop() !== undefined}>
<LineCommentEditor
top={draftTop()}
value={draft()}
selection={selectionLabel(range())}
onInput={setDraft}
onCancel={() => setCommenting(null)}
onSubmit={(comment) => {
props.onLineComment?.({
file: diff.file,
selection: range(),
comment,
preview: selectionPreview(diff, range()),
})
setCommenting(null)
}}
/>
</Show>
)}
</Show>
</Show>
</Show>
</div>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</Show>
</div>
</div>

View File

@@ -127,7 +127,49 @@
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 12px;
}
[data-slot="session-turn-diffs-group"] {
background-color: var(--background-stronger);
border-radius: var(--radius-lg);
border: 1px solid var(--border-weak-base);
overflow: clip;
[data-component="accordion"] {
gap: 0;
}
[data-component="accordion"]
[data-slot="accordion-item"]
[data-slot="accordion-header"]
[data-slot="accordion-trigger"] {
border: 0;
border-radius: 0;
box-shadow: none;
background-color: transparent;
&:hover {
background-color: var(--surface-base-hover);
}
&:active {
background-color: var(--surface-base-active);
}
}
[data-component="accordion"]
[data-slot="accordion-item"]
+ [data-slot="accordion-item"]
[data-slot="accordion-header"]
[data-slot="accordion-trigger"] {
border-top: 1px solid var(--border-weak-base);
}
[data-component="accordion"] [data-slot="accordion-item"][data-expanded] [data-slot="accordion-content"] {
border: 0;
border-top: 1px solid var(--border-weak-base);
border-radius: 0;
}
}
[data-slot="session-turn-diff-trigger"] {
@@ -140,12 +182,11 @@
}
[data-slot="session-turn-diff-path"] {
display: inline-flex;
display: flex;
min-width: 0;
align-items: baseline;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
@@ -153,6 +194,13 @@
[data-slot="session-turn-diff-directory"] {
color: var(--text-weak);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
unicode-bidi: plaintext;
text-align: left;
}
[data-slot="session-turn-diff-filename"] {

View File

@@ -315,76 +315,78 @@ export function SessionTurn(
<Collapsible.Content>
<Show when={open()}>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={diffs()}>
{(diff) => {
const active = createMemo(() => expanded().includes(diff.file))
const [visible, setVisible] = createSignal(false)
<div data-slot="session-turn-diffs-group">
<Accordion
multiple
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={diffs()}>
{(diff) => {
const active = createMemo(() => expanded().includes(diff.file))
const [visible, setVisible] = createSignal(false)
createEffect(
on(
active,
(value) => {
if (!value) {
setVisible(false)
return
}
createEffect(
on(
active,
(value) => {
if (!value) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
},
{ defer: true },
),
)
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
},
{ defer: true },
),
)
return (
<Accordion.Item value={diff.file}>
<Accordion.Header>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">
{getDirectory(diff.file)}
return (
<Accordion.Item value={diff.file}>
<Accordion.Header>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">
{`\u202A${getDirectory(diff.file)}\u202C`}
</span>
</Show>
<span data-slot="session-turn-diff-filename">
{getFilename(diff.file)}
</span>
</Show>
<span data-slot="session-turn-diff-filename">
{getFilename(diff.file)}
</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</div>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={diffComponent}
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={diffComponent}
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</div>
</Show>
</Collapsible.Content>

View File

@@ -253,16 +253,20 @@
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
color: var(--text-strong);
}
&:active:not(:disabled) {
background-color: var(--surface-base-active);
}
&:has([data-selected]) {
background-color: var(--surface-raised-base-active);
background-color: var(--surface-base-active);
color: var(--text-strong);
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-active);
background-color: var(--surface-base-active);
}
}
}
@@ -274,10 +278,11 @@
padding-inline: 12px;
gap: 8px;
align-items: center;
background-color: var(--background-stronger);
}
[data-slot="tabs-trigger-wrapper"] {
height: 26px;
height: 24px;
border-radius: 6px;
color: var(--text-weak);
@@ -325,11 +330,11 @@
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
}
&:has([data-selected]) {
background-color: var(--surface-raised-base-active);
background-color: var(--surface-base-active);
color: var(--text-strong);
}
}
@@ -363,11 +368,11 @@
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
}
&:has([data-selected]) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
color: var(--text-strong);
}
}
@@ -426,16 +431,16 @@
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
}
&:has([data-slot="tabs-trigger"]:focus-visible) {
background-color: var(--surface-raised-base-hover);
background-color: var(--surface-base-hover);
box-shadow: var(--shadow-xs-border-focus);
}
&:has([data-selected]) {
background-color: var(--surface-raised-base-active);
background-color: var(--surface-base-active);
color: var(--text-strong);
[data-component="icon"] {
@@ -443,13 +448,13 @@
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-active);
background-color: var(--surface-base-active);
}
}
}
[data-slot="tabs-content"] {
background-color: var(--surface-raised-stronger-non-alpha);
background-color: var(--surface-stronger-non-alpha);
}
}
}

View File

@@ -1,4 +1,100 @@
:root {
--gray-dark-1: #161616;
--gray-dark-2: #1c1c1c;
--gray-dark-3: #232323;
--gray-dark-4: #282828;
--gray-dark-5: #2e2e2e;
--gray-dark-6: #343434;
--gray-dark-7: #3e3e3e;
--gray-dark-8: #505050;
--gray-dark-9: #707070;
--gray-dark-10: #7e7e7e;
--gray-dark-11: #a0a0a0;
--gray-dark-12: #ededed;
--gray-light-1: #fcfcfc;
--gray-light-2: #f8f8f8;
--gray-light-3: #f3f3f3;
--gray-light-4: #ededed;
--gray-light-5: #e8e8e8;
--gray-light-6: #e2e2e2;
--gray-light-7: #dbdbdb;
--gray-light-8: #c7c7c7;
--gray-light-9: #8f8f8f;
--gray-light-10: #858585;
--gray-light-11: #6f6f6f;
--gray-light-12: #171717;
--gray-dark-alpha-1: #00000000;
--gray-dark-alpha-2: #ffffff08;
--gray-dark-alpha-3: #ffffff0f;
--gray-dark-alpha-4: #ffffff14;
--gray-dark-alpha-5: #ffffff1a;
--gray-dark-alpha-6: #ffffff21;
--gray-dark-alpha-7: #ffffff2b;
--gray-dark-alpha-8: #ffffff40;
--gray-dark-alpha-9: #ffffff63;
--gray-dark-alpha-10: #ffffff73;
--gray-dark-alpha-11: #ffffff96;
--gray-dark-alpha-12: #ffffffeb;
--gray-light-alpha-1: #00000003;
--gray-light-alpha-2: #00000008;
--gray-light-alpha-3: #0000000d;
--gray-light-alpha-4: #00000012;
--gray-light-alpha-5: #00000017;
--gray-light-alpha-6: #0000001c;
--gray-light-alpha-7: #00000024;
--gray-light-alpha-8: #00000038;
--gray-light-alpha-9: #00000070;
--gray-light-alpha-10: #0000007a;
--gray-light-alpha-11: #0000008f;
--gray-light-alpha-12: #000000e8;
--gray-dark-1: #131010;
--gray-dark-2: #1b1818;
--gray-dark-3: #252121;
--gray-dark-4: #2d2828;
--gray-dark-5: #343030;
--gray-dark-6: #3e3939;
--gray-dark-7: #4b4646;
--gray-dark-8: #645f5f;
--gray-dark-9: #716c6b;
--gray-dark-10: #7f7979;
--gray-dark-11: #b7b1b1;
--gray-dark-12: #f1ecec;
--gray-light-1: #fdfcfc;
--gray-light-2: #f9f8f8;
--gray-light-3: #f1f0f0;
--gray-light-4: #e9e8e8;
--gray-light-5: #e2e0e0;
--gray-light-6: #dad9d9;
--gray-light-7: #cfcecd;
--gray-light-8: #bcbbbb;
--gray-light-9: #8e8b8b;
--gray-light-10: #848181;
--gray-light-11: #656363;
--gray-light-12: #211e1e;
--gray-dark-alpha-1: #82383803;
--gray-dark-alpha-2: #e6c6c60b;
--gray-dark-alpha-3: #edd5d516;
--gray-dark-alpha-4: #f2e1e11e;
--gray-dark-alpha-5: #f5e8e826;
--gray-dark-alpha-6: #f5e8e831;
--gray-dark-alpha-7: #f7ecec3f;
--gray-dark-alpha-8: #faf5f559;
--gray-dark-alpha-9: #faf5f467;
--gray-dark-alpha-10: #fbf5f576;
--gray-dark-alpha-11: #fcf9f9b2;
--gray-dark-alpha-12: #fdfbfbf0;
--gray-light-alpha-1: #55000003;
--gray-light-alpha-2: #25000007;
--gray-light-alpha-3: #1100000f;
--gray-light-alpha-4: #0c000017;
--gray-light-alpha-5: #1100001f;
--gray-light-alpha-6: #07000026;
--gray-light-alpha-7: #0b060032;
--gray-light-alpha-8: #04000044;
--gray-light-alpha-9: #07000074;
--gray-light-alpha-10: #0400009c;
--gray-light-alpha-11: #0700007e;
--gray-light-alpha-12: #020000df;
--smoke-dark-1: #131010;
--smoke-dark-2: #1b1818;
--smoke-dark-3: #252121;
@@ -589,4 +685,88 @@
--amber-dark-alpha-10: #ffce48f9;
--amber-dark-alpha-11: #ffab0eef;
--amber-dark-alpha-12: #fff8e1f9;
/* Legacy palette aliases (keeps older themes working) */
--smoke-light-1: var(--gray-light-1);
--smoke-light-2: var(--gray-light-2);
--smoke-light-3: var(--gray-light-3);
--smoke-light-4: var(--gray-light-4);
--smoke-light-5: var(--gray-light-5);
--smoke-light-6: var(--gray-light-6);
--smoke-light-7: var(--gray-light-7);
--smoke-light-8: var(--gray-light-8);
--smoke-light-9: var(--gray-light-9);
--smoke-light-10: var(--gray-light-10);
--smoke-light-11: var(--gray-light-11);
--smoke-light-12: var(--gray-light-12);
--smoke-dark-1: var(--gray-dark-1);
--smoke-dark-2: var(--gray-dark-2);
--smoke-dark-3: var(--gray-dark-3);
--smoke-dark-4: var(--gray-dark-4);
--smoke-dark-5: var(--gray-dark-5);
--smoke-dark-6: var(--gray-dark-6);
--smoke-dark-7: var(--gray-dark-7);
--smoke-dark-8: var(--gray-dark-8);
--smoke-dark-9: var(--gray-dark-9);
--smoke-dark-10: var(--gray-dark-10);
--smoke-dark-11: var(--gray-dark-11);
--smoke-dark-12: var(--gray-dark-12);
--smoke-light-alpha-1: var(--gray-light-alpha-1);
--smoke-light-alpha-2: var(--gray-light-alpha-2);
--smoke-light-alpha-3: var(--gray-light-alpha-3);
--smoke-light-alpha-4: var(--gray-light-alpha-4);
--smoke-light-alpha-5: var(--gray-light-alpha-5);
--smoke-light-alpha-6: var(--gray-light-alpha-6);
--smoke-light-alpha-7: var(--gray-light-alpha-7);
--smoke-light-alpha-8: var(--gray-light-alpha-8);
--smoke-light-alpha-9: var(--gray-light-alpha-9);
--smoke-light-alpha-10: var(--gray-light-alpha-10);
--smoke-light-alpha-11: var(--gray-light-alpha-11);
--smoke-light-alpha-12: var(--gray-light-alpha-12);
--smoke-dark-alpha-1: var(--gray-dark-alpha-1);
--smoke-dark-alpha-2: var(--gray-dark-alpha-2);
--smoke-dark-alpha-3: var(--gray-dark-alpha-3);
--smoke-dark-alpha-4: var(--gray-dark-alpha-4);
--smoke-dark-alpha-5: var(--gray-dark-alpha-5);
--smoke-dark-alpha-6: var(--gray-dark-alpha-6);
--smoke-dark-alpha-7: var(--gray-dark-alpha-7);
--smoke-dark-alpha-8: var(--gray-dark-alpha-8);
--smoke-dark-alpha-9: var(--gray-dark-alpha-9);
--smoke-dark-alpha-10: var(--gray-dark-alpha-10);
--smoke-dark-alpha-11: var(--gray-dark-alpha-11);
--smoke-dark-alpha-12: var(--gray-dark-alpha-12);
--amber-lightalpha-1: var(--amber-light-alpha-1);
--amber-lightalpha-2: var(--amber-light-alpha-2);
--amber-lightalpha-3: var(--amber-light-alpha-3);
--amber-lightalpha-4: var(--amber-light-alpha-4);
--amber-lightalpha-5: var(--amber-light-alpha-5);
--amber-lightalpha-6: var(--amber-light-alpha-6);
--amber-lightalpha-7: var(--amber-light-alpha-7);
--amber-lightalpha-8: var(--amber-light-alpha-8);
--amber-lightalpha-9: var(--amber-light-alpha-9);
--amber-lightalpha-10: var(--amber-light-alpha-10);
--amber-lightalpha-11: var(--amber-light-alpha-11);
--amber-lightalpha-12: var(--amber-light-alpha-12);
--amber-darkalpha-1: var(--amber-dark-alpha-1);
--amber-darkalpha-2: var(--amber-dark-alpha-2);
--amber-darkalpha-3: var(--amber-dark-alpha-3);
--amber-darkalpha-4: var(--amber-dark-alpha-4);
--amber-darkalpha-5: var(--amber-dark-alpha-5);
--amber-darkalpha-6: var(--amber-dark-alpha-6);
--amber-darkalpha-7: var(--amber-dark-alpha-7);
--amber-darkalpha-8: var(--amber-dark-alpha-8);
--amber-darkalpha-9: var(--amber-dark-alpha-9);
--amber-darkalpha-10: var(--amber-dark-alpha-10);
--amber-darkalpha-11: var(--amber-dark-alpha-11);
--amber-darkalpha-12: var(--amber-dark-alpha-12);
--purple-light-9: var(--lilac-light-9);
--purple-dark-9: var(--lilac-dark-9);
--cyan-light-9: var(--blue-light-9);
--cyan-dark-9: var(--blue-dark-9);
}

View File

@@ -1,5 +1,6 @@
import type { DesktopTheme } from "./types"
import oc1ThemeJson from "./themes/oc-1.json"
import oc2ThemeJson from "./themes/oc-2.json"
import tokyoThemeJson from "./themes/tokyonight.json"
import draculaThemeJson from "./themes/dracula.json"
import monokaiThemeJson from "./themes/monokai.json"
@@ -16,6 +17,7 @@ import gruvboxThemeJson from "./themes/gruvbox.json"
import auraThemeJson from "./themes/aura.json"
export const oc1Theme = oc1ThemeJson as DesktopTheme
export const oc2Theme = oc2ThemeJson as DesktopTheme
export const tokyonightTheme = tokyoThemeJson as DesktopTheme
export const draculaTheme = draculaThemeJson as DesktopTheme
export const monokaiTheme = monokaiThemeJson as DesktopTheme
@@ -33,6 +35,7 @@ export const auraTheme = auraThemeJson as DesktopTheme
export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
"oc-1": oc1Theme,
"oc-2": oc2Theme,
aura: auraTheme,
ayu: ayuTheme,
carbonfox: carbonfoxTheme,

View File

@@ -32,6 +32,7 @@ export { ThemeProvider, useTheme, type ColorScheme } from "./context"
export {
DEFAULT_THEMES,
oc1Theme,
oc2Theme,
tokyonightTheme,
draculaTheme,
monokaiTheme,

View File

@@ -0,0 +1,532 @@
{
"$schema": "https://opencode.ai/desktop-theme.json",
"name": "OC-2",
"id": "oc-2",
"light": {
"seeds": {
"neutral": "#8e8b8b",
"primary": "#dcde8d",
"success": "#12c905",
"warning": "#ffdc17",
"error": "#fc533a",
"info": "#a753ae",
"interactive": "#034cff",
"diffAdd": "#9ff29a",
"diffDelete": "#fc533a"
},
"overrides": {
"background-base": "#f8f7f7",
"background-weak": "var(--gray-light-3)",
"background-strong": "var(--gray-light-1)",
"background-stronger": "#fcfcfc",
"surface-base": "var(--gray-light-alpha-2)",
"base": "var(--gray-light-alpha-2)",
"surface-base-hover": "#0500000f",
"surface-base-active": "var(--gray-light-alpha-3)",
"surface-base-interactive-active": "var(--cobalt-light-alpha-3)",
"base2": "var(--gray-light-alpha-2)",
"base3": "var(--gray-light-alpha-2)",
"surface-inset-base": "var(--gray-light-alpha-2)",
"surface-inset-base-hover": "var(--gray-light-alpha-3)",
"surface-inset-strong": "#1f000017",
"surface-inset-strong-hover": "#1f000017",
"surface-raised-base": "var(--gray-light-alpha-2)",
"surface-float-base": "var(--gray-dark-1)",
"surface-float-base-hover": "var(--gray-dark-2)",
"surface-raised-base-hover": "var(--gray-light-alpha-3)",
"surface-raised-base-active": "var(--gray-light-alpha-5)",
"surface-raised-strong": "var(--gray-light-1)",
"surface-raised-strong-hover": "var(--white)",
"surface-raised-stronger": "var(--white)",
"surface-raised-stronger-hover": "var(--white)",
"surface-weak": "var(--gray-light-alpha-3)",
"surface-weaker": "var(--gray-light-alpha-4)",
"surface-strong": "#ffffff",
"surface-raised-stronger-non-alpha": "var(--white)",
"surface-brand-base": "var(--yuzu-light-9)",
"surface-brand-hover": "var(--yuzu-light-10)",
"surface-interactive-base": "var(--cobalt-light-3)",
"surface-interactive-hover": "#E5F0FF",
"surface-interactive-weak": "var(--cobalt-light-2)",
"surface-interactive-weak-hover": "var(--cobalt-light-3)",
"surface-success-base": "var(--apple-light-3)",
"surface-success-weak": "var(--apple-light-2)",
"surface-success-strong": "var(--apple-light-9)",
"surface-warning-base": "var(--solaris-light-3)",
"surface-warning-weak": "var(--solaris-light-2)",
"surface-warning-strong": "var(--solaris-light-9)",
"surface-critical-base": "var(--ember-light-3)",
"surface-critical-weak": "var(--ember-light-2)",
"surface-critical-strong": "var(--ember-light-9)",
"surface-info-base": "var(--lilac-light-3)",
"surface-info-weak": "var(--lilac-light-2)",
"surface-info-strong": "var(--lilac-light-9)",
"surface-diff-unchanged-base": "#ffffff00",
"surface-diff-skip-base": "var(--gray-light-2)",
"surface-diff-hidden-base": "var(--blue-light-3)",
"surface-diff-hidden-weak": "var(--blue-light-2)",
"surface-diff-hidden-weaker": "var(--blue-light-1)",
"surface-diff-hidden-strong": "var(--blue-light-5)",
"surface-diff-hidden-stronger": "var(--blue-light-9)",
"surface-diff-add-base": "#dafbe0",
"surface-diff-add-weak": "var(--mint-light-2)",
"surface-diff-add-weaker": "var(--mint-light-1)",
"surface-diff-add-strong": "var(--mint-light-5)",
"surface-diff-add-stronger": "var(--mint-light-9)",
"surface-diff-delete-base": "var(--ember-light-3)",
"surface-diff-delete-weak": "var(--ember-light-2)",
"surface-diff-delete-weaker": "var(--ember-light-1)",
"surface-diff-delete-strong": "var(--ember-light-6)",
"surface-diff-delete-stronger": "var(--ember-light-9)",
"input-base": "var(--gray-light-1)",
"input-hover": "var(--gray-light-2)",
"input-active": "var(--cobalt-light-1)",
"input-selected": "var(--cobalt-light-4)",
"input-focus": "var(--cobalt-light-1)",
"input-disabled": "var(--gray-light-4)",
"text-base": "var(--gray-light-11)",
"text-weak": "var(--gray-light-9)",
"text-weaker": "var(--gray-light-8)",
"text-strong": "var(--gray-light-12)",
"text-invert-base": "var(--gray-dark-alpha-11)",
"text-invert-weak": "var(--gray-dark-alpha-9)",
"text-invert-weaker": "var(--gray-dark-alpha-8)",
"text-invert-strong": "var(--gray-dark-alpha-12)",
"text-interactive-base": "var(--cobalt-light-9)",
"text-on-brand-base": "var(--gray-light-alpha-11)",
"text-on-interactive-base": "var(--gray-light-1)",
"text-on-interactive-weak": "var(--gray-dark-alpha-11)",
"text-on-success-base": "var(--apple-light-10)",
"text-on-critical-base": "var(--ember-light-10)",
"text-on-critical-weak": "var(--ember-light-8)",
"text-on-critical-strong": "var(--ember-light-12)",
"text-on-warning-base": "var(--gray-dark-alpha-11)",
"text-on-info-base": "var(--gray-dark-alpha-11)",
"text-diff-add-base": "var(--mint-light-11)",
"text-diff-delete-base": "var(--ember-light-10)",
"text-diff-delete-strong": "var(--ember-light-12)",
"text-diff-add-strong": "var(--mint-light-12)",
"text-on-info-weak": "var(--gray-dark-alpha-9)",
"text-on-info-strong": "var(--gray-dark-alpha-12)",
"text-on-warning-weak": "var(--gray-dark-alpha-9)",
"text-on-warning-strong": "var(--gray-dark-alpha-12)",
"text-on-success-weak": "var(--apple-light-6)",
"text-on-success-strong": "var(--apple-light-12)",
"text-on-brand-weak": "var(--gray-light-alpha-9)",
"text-on-brand-weaker": "var(--gray-light-alpha-8)",
"text-on-brand-strong": "var(--gray-light-alpha-12)",
"button-primary-base": "var(--gray-light-12)",
"button-secondary-base": "var(--gray-light-1)",
"button-secondary-hover": "FFFFFF0A",
"border-base": "var(--gray-light-alpha-7)",
"border-hover": "var(--gray-light-alpha-8)",
"border-active": "var(--gray-light-alpha-9)",
"border-selected": "var(--cobalt-light-alpha-9)",
"border-disabled": "var(--gray-light-alpha-8)",
"border-focus": "var(--gray-light-alpha-9)",
"border-weak-base": "var(--gray-light-alpha-5)",
"border-strong-base": "var(--gray-light-alpha-7)",
"border-strong-hover": "var(--gray-light-alpha-8)",
"border-strong-active": "var(--gray-light-alpha-7)",
"border-strong-selected": "var(--cobalt-light-alpha-6)",
"border-strong-disabled": "var(--gray-light-alpha-6)",
"border-strong-focus": "var(--gray-light-alpha-7)",
"border-weak-hover": "var(--gray-light-alpha-6)",
"border-weak-active": "var(--gray-light-alpha-7)",
"border-weak-selected": "var(--cobalt-light-alpha-5)",
"border-weak-disabled": "var(--gray-light-alpha-6)",
"border-weak-focus": "var(--gray-light-alpha-7)",
"border-interactive-base": "var(--cobalt-light-7)",
"border-interactive-hover": "var(--cobalt-light-8)",
"border-interactive-active": "var(--cobalt-light-9)",
"border-interactive-selected": "var(--cobalt-light-9)",
"border-interactive-disabled": "var(--gray-light-8)",
"border-interactive-focus": "var(--cobalt-light-9)",
"border-success-base": "var(--apple-light-6)",
"border-success-hover": "var(--apple-light-7)",
"border-success-selected": "var(--apple-light-9)",
"border-warning-base": "var(--solaris-light-6)",
"border-warning-hover": "var(--solaris-light-7)",
"border-warning-selected": "var(--solaris-light-9)",
"border-critical-base": "var(--ember-light-6)",
"border-critical-hover": "var(--ember-light-7)",
"border-critical-selected": "var(--ember-light-9)",
"border-info-base": "var(--lilac-light-6)",
"border-info-hover": "var(--lilac-light-7)",
"border-info-selected": "var(--lilac-light-9)",
"icon-base": "var(--gray-light-9)",
"icon-hover": "var(--gray-light-11)",
"icon-active": "var(--gray-light-12)",
"icon-selected": "var(--gray-light-12)",
"icon-disabled": "var(--gray-light-8)",
"icon-focus": "var(--gray-light-12)",
"icon-invert-base": "#ffffff",
"icon-weak-base": "var(--gray-light-7)",
"icon-weak-hover": "var(--gray-light-8)",
"icon-weak-active": "var(--gray-light-9)",
"icon-weak-selected": "var(--gray-light-10)",
"icon-weak-disabled": "var(--gray-light-6)",
"icon-weak-focus": "var(--gray-light-9)",
"icon-strong-base": "var(--gray-light-12)",
"icon-strong-hover": "#151313",
"icon-strong-active": "#020202",
"icon-strong-selected": "#020202",
"icon-strong-disabled": "var(--gray-light-6)",
"icon-strong-focus": "#020202",
"icon-brand-base": "var(--gray-light-12)",
"icon-interactive-base": "var(--cobalt-light-9)",
"icon-success-base": "var(--apple-light-7)",
"icon-success-hover": "var(--apple-light-8)",
"icon-success-active": "var(--apple-light-11)",
"icon-warning-base": "var(--amber-light-7)",
"icon-warning-hover": "var(--amber-light-8)",
"icon-warning-active": "var(--amber-light-11)",
"icon-critical-base": "var(--ember-light-10)",
"icon-critical-hover": "var(--ember-light-11)",
"icon-critical-active": "var(--ember-light-12)",
"icon-info-base": "var(--lilac-light-7)",
"icon-info-hover": "var(--lilac-light-8)",
"icon-info-active": "var(--lilac-light-11)",
"icon-on-brand-base": "var(--gray-light-alpha-11)",
"icon-on-brand-hover": "var(--gray-light-alpha-12)",
"icon-on-brand-selected": "var(--gray-light-alpha-12)",
"icon-on-interactive-base": "var(--gray-light-1)",
"icon-agent-plan-base": "var(--purple-light-9)",
"icon-agent-docs-base": "var(--amber-light-9)",
"icon-agent-ask-base": "var(--cyan-light-9)",
"icon-agent-build-base": "var(--cobalt-light-9)",
"icon-on-success-base": "var(--apple-light-alpha-9)",
"icon-on-success-hover": "var(--apple-light-alpha-10)",
"icon-on-success-selected": "var(--apple-light-alpha-11)",
"icon-on-warning-base": "var(--amber-lightalpha-9)",
"icon-on-warning-hover": "var(--amber-lightalpha-10)",
"icon-on-warning-selected": "var(--amber-lightalpha-11)",
"icon-on-critical-base": "var(--ember-light-alpha-9)",
"icon-on-critical-hover": "var(--ember-light-alpha-10)",
"icon-on-critical-selected": "var(--ember-light-alpha-11)",
"icon-on-info-base": "var(--lilac-light-9)",
"icon-on-info-hover": "var(--lilac-light-alpha-10)",
"icon-on-info-selected": "var(--lilac-light-alpha-11)",
"icon-diff-add-base": "var(--mint-light-11)",
"icon-diff-add-hover": "var(--mint-light-12)",
"icon-diff-add-active": "var(--mint-light-12)",
"icon-diff-delete-base": "var(--ember-light-10)",
"icon-diff-delete-hover": "var(--ember-light-11)",
"syntax-comment": "var(--text-weak)",
"syntax-regexp": "var(--text-base)",
"syntax-string": "#006656",
"syntax-keyword": "var(--text-weak)",
"syntax-primitive": "#fb4804",
"syntax-operator": "var(--text-base)",
"syntax-variable": "var(--text-strong)",
"syntax-property": "#ed6dc8",
"syntax-type": "#596600",
"syntax-constant": "#007b80",
"syntax-punctuation": "var(--text-base)",
"syntax-object": "var(--text-strong)",
"syntax-success": "var(--apple-light-10)",
"syntax-warning": "var(--amber-light-10)",
"syntax-critical": "var(--ember-light-10)",
"syntax-info": "#0092a8",
"syntax-diff-add": "var(--mint-light-11)",
"syntax-diff-delete": "var(--ember-light-11)",
"syntax-diff-unknown": "#ff0000",
"markdown-heading": "#d68c27",
"markdown-text": "#1a1a1a",
"markdown-link": "#3b7dd8",
"markdown-link-text": "#318795",
"markdown-code": "#3d9a57",
"markdown-block-quote": "#b0851f",
"markdown-emph": "#b0851f",
"markdown-strong": "#d68c27",
"markdown-horizontal-rule": "#8a8a8a",
"markdown-list-item": "#3b7dd8",
"markdown-list-enumeration": "#318795",
"markdown-image": "#3b7dd8",
"markdown-image-text": "#318795",
"markdown-code-block": "#1a1a1a",
"border-color": "#ffffff",
"border-weaker-base": "var(--gray-light-alpha-3)",
"border-weaker-hover": "var(--gray-light-alpha-4)",
"border-weaker-active": "var(--gray-light-alpha-6)",
"border-weaker-selected": "var(--cobalt-light-alpha-4)",
"border-weaker-disabled": "var(--gray-light-alpha-2)",
"border-weaker-focus": "var(--gray-light-alpha-6)",
"button-ghost-hover": "var(--gray-light-alpha-2)",
"button-ghost-hover2": "var(--gray-light-alpha-3)",
"avatar-background-pink": "#feeef8",
"avatar-background-mint": "#e1fbf4",
"avatar-background-orange": "#fff1e7",
"avatar-background-purple": "#f9f1fe",
"avatar-background-cyan": "#e7f9fb",
"avatar-background-lime": "#eefadc",
"avatar-text-pink": "#cd1d8d",
"avatar-text-mint": "#147d6f",
"avatar-text-orange": "#ed5f00",
"avatar-text-purple": "#8445bc",
"avatar-text-cyan": "#0894b3",
"avatar-text-lime": "#5d770d"
}
},
"dark": {
"seeds": {
"neutral": "#716c6b",
"primary": "#fab283",
"success": "#12c905",
"warning": "#fcd53a",
"error": "#fc533a",
"info": "#edb2f1",
"interactive": "#034cff",
"diffAdd": "#c8ffc4",
"diffDelete": "#fc533a"
},
"overrides": {
"base": "var(--gray-dark-alpha-2)",
"base2": "var(--gray-dark-alpha-2)",
"base3": "var(--gray-dark-alpha-2)",
"background-base": "#101010",
"background-weak": "#1E1E1E",
"background-strong": "#121212",
"background-stronger": "#151515",
"surface-base": "var(--gray-dark-alpha-2)",
"surface-base-hover": "#FFFFFF0A",
"surface-base-active": "var(--gray-dark-alpha-3)",
"surface-base-interactive-active": "var(--cobalt-dark-alpha-2)",
"surface-inset-base": "#0e0b0b7f",
"surface-inset-base-hover": "#0e0b0b7f",
"surface-inset-strong": "#060505cc",
"surface-inset-strong-hover": "#060505cc",
"surface-raised-base": "var(--gray-dark-alpha-3)",
"surface-float-base": "var(--gray-dark-1)",
"surface-float-base-hover": "var(--gray-dark-2)",
"surface-raised-base-hover": "var(--gray-dark-alpha-4)",
"surface-raised-base-active": "var(--gray-dark-alpha-5)",
"surface-raised-strong": "var(--gray-dark-alpha-4)",
"surface-raised-strong-hover": "var(--gray-dark-alpha-6)",
"surface-raised-stronger": "var(--gray-dark-alpha-6)",
"surface-raised-stronger-hover": "var(--gray-dark-alpha-7)",
"surface-weak": "var(--gray-dark-alpha-4)",
"surface-weaker": "var(--gray-dark-alpha-5)",
"surface-strong": "var(--gray-dark-alpha-7)",
"surface-raised-stronger-non-alpha": "#1B1B1B",
"surface-brand-base": "var(--yuzu-light-9)",
"surface-brand-hover": "var(--yuzu-light-10)",
"surface-interactive-base": "var(--cobalt-dark-3)",
"surface-interactive-hover": "#0A1D4D",
"surface-interactive-weak": "var(--cobalt-dark-2)",
"surface-interactive-weak-hover": "var(--cobalt-light-3)",
"surface-success-base": "var(--apple-dark-3)",
"surface-success-weak": "var(--apple-dark-2)",
"surface-success-strong": "var(--apple-dark-9)",
"surface-warning-base": "var(--solaris-light-3)",
"surface-warning-weak": "var(--solaris-light-2)",
"surface-warning-strong": "var(--solaris-light-9)",
"surface-critical-base": "var(--ember-dark-3)",
"surface-critical-weak": "var(--ember-dark-2)",
"surface-critical-strong": "var(--ember-dark-9)",
"surface-info-base": "var(--lilac-light-3)",
"surface-info-weak": "var(--lilac-light-2)",
"surface-info-strong": "var(--lilac-light-9)",
"surface-diff-unchanged-base": "var(--gray-dark-1)",
"surface-diff-skip-base": "var(--gray-dark-alpha-1)",
"surface-diff-hidden-base": "var(--blue-dark-2)",
"surface-diff-hidden-weak": "var(--blue-dark-1)",
"surface-diff-hidden-weaker": "var(--blue-dark-3)",
"surface-diff-hidden-strong": "var(--blue-dark-5)",
"surface-diff-hidden-stronger": "var(--blue-dark-11)",
"surface-diff-add-base": "var(--mint-dark-3)",
"surface-diff-add-weak": "var(--mint-dark-4)",
"surface-diff-add-weaker": "var(--mint-dark-3)",
"surface-diff-add-strong": "var(--mint-dark-5)",
"surface-diff-add-stronger": "var(--mint-dark-11)",
"surface-diff-delete-base": "var(--ember-dark-3)",
"surface-diff-delete-weak": "var(--ember-dark-4)",
"surface-diff-delete-weaker": "var(--ember-dark-3)",
"surface-diff-delete-strong": "var(--ember-dark-5)",
"surface-diff-delete-stronger": "var(--ember-dark-11)",
"input-base": "var(--gray-dark-2)",
"input-hover": "var(--gray-dark-2)",
"input-active": "var(--cobalt-dark-1)",
"input-selected": "var(--cobalt-dark-2)",
"input-focus": "var(--cobalt-dark-1)",
"input-disabled": "var(--gray-dark-4)",
"text-base": "var(--gray-dark-alpha-11)",
"text-weak": "var(--gray-dark-alpha-9)",
"text-weaker": "var(--gray-dark-alpha-8)",
"text-strong": "var(--gray-dark-alpha-12)",
"text-invert-base": "var(--gray-dark-alpha-11)",
"text-invert-weak": "var(--gray-dark-alpha-9)",
"text-invert-weaker": "var(--gray-dark-alpha-8)",
"text-invert-strong": "var(--gray-dark-alpha-12)",
"text-interactive-base": "var(--cobalt-dark-11)",
"text-on-brand-base": "var(--gray-dark-alpha-11)",
"text-on-interactive-base": "var(--gray-dark-12)",
"text-on-interactive-weak": "var(--gray-dark-alpha-11)",
"text-on-success-base": "var(--apple-dark-9)",
"text-on-critical-base": "var(--ember-dark-9)",
"text-on-critical-weak": "var(--ember-dark-8)",
"text-on-critical-strong": "var(--ember-dark-12)",
"text-on-warning-base": "var(--gray-dark-alpha-11)",
"text-on-info-base": "var(--gray-dark-alpha-11)",
"text-diff-add-base": "var(--mint-dark-11)",
"text-diff-delete-base": "var(--ember-dark-9)",
"text-diff-delete-strong": "var(--ember-dark-12)",
"text-diff-add-strong": "var(--mint-dark-8)",
"text-on-info-weak": "var(--gray-dark-alpha-9)",
"text-on-info-strong": "var(--gray-dark-alpha-12)",
"text-on-warning-weak": "var(--gray-dark-alpha-9)",
"text-on-warning-strong": "var(--gray-dark-alpha-12)",
"text-on-success-weak": "var(--apple-dark-8)",
"text-on-success-strong": "var(--apple-dark-12)",
"text-on-brand-weak": "var(--gray-dark-alpha-9)",
"text-on-brand-weaker": "var(--gray-dark-alpha-8)",
"text-on-brand-strong": "var(--gray-dark-alpha-12)",
"button-primary-base": "var(--gray-dark-12)",
"button-secondary-base": "var(--gray-dark-2)",
"button-secondary-hover": "#FFFFFF0A",
"border-base": "var(--gray-dark-alpha-7)",
"border-hover": "var(--gray-dark-alpha-8)",
"border-active": "var(--gray-dark-alpha-9)",
"border-selected": "var(--cobalt-dark-alpha-11)",
"border-disabled": "var(--gray-dark-alpha-8)",
"border-focus": "var(--gray-dark-alpha-9)",
"border-weak-base": "var(--gray-dark-alpha-5)",
"border-weak-hover": "var(--gray-dark-alpha-7)",
"border-weak-active": "var(--gray-dark-alpha-8)",
"border-weak-selected": "var(--cobalt-dark-alpha-6)",
"border-weak-disabled": "var(--gray-dark-alpha-6)",
"border-weak-focus": "var(--gray-dark-alpha-8)",
"border-strong-base": "var(--gray-dark-alpha-8)",
"border-interactive-base": "var(--cobalt-light-7)",
"border-interactive-hover": "var(--cobalt-light-8)",
"border-interactive-active": "var(--cobalt-light-9)",
"border-interactive-selected": "var(--cobalt-light-9)",
"border-interactive-disabled": "var(--gray-light-8)",
"border-interactive-focus": "var(--cobalt-light-9)",
"border-success-base": "var(--apple-light-6)",
"border-success-hover": "var(--apple-light-7)",
"border-success-selected": "var(--apple-light-9)",
"border-warning-base": "var(--solaris-light-6)",
"border-warning-hover": "var(--solaris-light-7)",
"border-warning-selected": "var(--solaris-light-9)",
"border-critical-base": "var(--ember-dark-5)",
"border-critical-hover": "var(--ember-dark-7)",
"border-critical-selected": "var(--ember-dark-9)",
"border-info-base": "var(--lilac-light-6)",
"border-info-hover": "var(--lilac-light-7)",
"border-info-selected": "var(--lilac-light-9)",
"icon-base": "var(--gray-dark-10)",
"icon-hover": "var(--gray-dark-11)",
"icon-active": "var(--gray-dark-12)",
"icon-selected": "var(--gray-dark-12)",
"icon-disabled": "var(--gray-dark-8)",
"icon-focus": "var(--gray-dark-12)",
"icon-invert-base": "var(--gray-dark-1)",
"icon-weak-base": "var(--gray-dark-6)",
"icon-weak-hover": "var(--gray-light-7)",
"icon-weak-active": "var(--gray-light-8)",
"icon-weak-selected": "var(--gray-light-9)",
"icon-weak-disabled": "var(--gray-light-4)",
"icon-weak-focus": "var(--gray-light-9)",
"icon-strong-base": "var(--gray-dark-12)",
"icon-strong-hover": "#F3F3F3",
"icon-strong-active": "#EBEBEB",
"icon-strong-selected": "#FCFCFC",
"icon-strong-disabled": "var(--gray-dark-7)",
"icon-strong-focus": "#FCFCFC",
"icon-brand-base": "var(--white)",
"icon-interactive-base": "var(--cobalt-dark-11)",
"icon-success-base": "var(--apple-dark-9)",
"icon-success-hover": "var(--apple-dark-10)",
"icon-success-active": "var(--apple-dark-11)",
"icon-warning-base": "var(--amber-dark-9)",
"icon-warning-hover": "var(--amber-dark-8)",
"icon-warning-active": "var(--amber-dark-11)",
"icon-critical-base": "var(--ember-dark-9)",
"icon-critical-hover": "var(--ember-dark-11)",
"icon-critical-active": "var(--ember-dark-12)",
"icon-info-base": "var(--lilac-dark-7)",
"icon-info-hover": "var(--lilac-dark-8)",
"icon-info-active": "var(--lilac-dark-11)",
"icon-on-brand-base": "var(--gray-light-alpha-11)",
"icon-on-brand-hover": "var(--gray-light-alpha-12)",
"icon-on-brand-selected": "var(--gray-light-alpha-12)",
"icon-on-interactive-base": "var(--gray-dark-12)",
"icon-agent-plan-base": "var(--purple-dark-9)",
"icon-agent-docs-base": "var(--amber-dark-9)",
"icon-agent-ask-base": "var(--cyan-dark-9)",
"icon-agent-build-base": "var(--cobalt-dark-11)",
"icon-on-success-base": "var(--apple-dark-alpha-9)",
"icon-on-success-hover": "var(--apple-dark-alpha-10)",
"icon-on-success-selected": "var(--apple-dark-alpha-11)",
"icon-on-warning-base": "var(--amber-darkalpha-9)",
"icon-on-warning-hover": "var(--amber-darkalpha-10)",
"icon-on-warning-selected": "var(--amber-darkalpha-11)",
"icon-on-critical-base": "var(--ember-dark-alpha-9)",
"icon-on-critical-hover": "var(--ember-dark-alpha-10)",
"icon-on-critical-selected": "var(--ember-dark-alpha-11)",
"icon-on-info-base": "var(--lilac-dark-9)",
"icon-on-info-hover": "var(--lilac-dark-alpha-10)",
"icon-on-info-selected": "var(--lilac-dark-alpha-11)",
"icon-diff-add-base": "var(--mint-dark-11)",
"icon-diff-add-hover": "var(--mint-dark-10)",
"icon-diff-add-active": "var(--mint-dark-11)",
"icon-diff-delete-base": "var(--ember-dark-9)",
"icon-diff-delete-hover": "var(--ember-dark-10)",
"syntax-comment": "var(--text-weak)",
"syntax-regexp": "var(--text-base)",
"syntax-string": "#00ceb9",
"syntax-keyword": "var(--text-weak)",
"syntax-primitive": "#ffba92",
"syntax-operator": "var(--text-weak)",
"syntax-variable": "var(--text-strong)",
"syntax-property": "#ff9ae2",
"syntax-type": "#ecf58c",
"syntax-constant": "#93e9f6",
"syntax-punctuation": "var(--text-weak)",
"syntax-object": "var(--text-strong)",
"syntax-success": "var(--apple-dark-10)",
"syntax-warning": "var(--amber-dark-10)",
"syntax-critical": "var(--ember-dark-10)",
"syntax-info": "#93e9f6",
"syntax-diff-add": "var(--mint-dark-11)",
"syntax-diff-delete": "var(--ember-dark-11)",
"syntax-diff-unknown": "#ff0000",
"markdown-heading": "#9d7cd8",
"markdown-text": "#eeeeee",
"markdown-link": "#fab283",
"markdown-link-text": "#56b6c2",
"markdown-code": "#7fd88f",
"markdown-block-quote": "#e5c07b",
"markdown-emph": "#e5c07b",
"markdown-strong": "#f5a742",
"markdown-horizontal-rule": "#808080",
"markdown-list-item": "#fab283",
"markdown-list-enumeration": "#56b6c2",
"markdown-image": "#fab283",
"markdown-image-text": "#56b6c2",
"markdown-code-block": "#eeeeee",
"border-color": "#ffffff",
"border-weaker-base": "var(--gray-dark-alpha-3)",
"border-weaker-hover": "var(--gray-dark-alpha-4)",
"border-weaker-active": "var(--gray-dark-alpha-6)",
"border-weaker-selected": "var(--cobalt-dark-alpha-3)",
"border-weaker-disabled": "var(--gray-dark-alpha-2)",
"border-weaker-focus": "var(--gray-dark-alpha-6)",
"button-ghost-hover": "var(--gray-dark-alpha-2)",
"button-ghost-hover2": "var(--gray-dark-alpha-3)",
"avatar-background-pink": "#501b3f",
"avatar-background-mint": "#033a34",
"avatar-background-orange": "#5f2a06",
"avatar-background-purple": "#432155",
"avatar-background-cyan": "#0f3058",
"avatar-background-lime": "#2b3711",
"avatar-text-pink": "#e34ba9",
"avatar-text-mint": "#95f3d9",
"avatar-text-orange": "#ff802b",
"avatar-text-purple": "#9d5bd2",
"avatar-text-cyan": "#369eff",
"avatar-text-lime": "#c4f042"
}
}
}

79
script/duplicate-issue.ts Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bun
import path from "path"
import { pathToFileURL } from "bun"
import { createOpencode } from "@opencode-ai/sdk"
import { parseArgs } from "util"
async function main() {
const { values, positionals } = parseArgs({
args: Bun.argv.slice(2),
options: {
file: { type: "string", short: "f" },
help: { type: "boolean", short: "h", default: false },
},
allowPositionals: true,
})
if (values.help) {
console.log(`
Usage: bun script/duplicate-issue.ts [options] <message>
Options:
-f, --file <path> File to attach to the prompt
-h, --help Show this help message
Examples:
bun script/duplicate-issue.ts -f issue_info.txt "Check the attached file for issue details"
`)
process.exit(0)
}
const message = positionals.join(" ")
if (!message) {
console.error("Error: message is required")
process.exit(1)
}
const opencode = await createOpencode({ port: 0 })
try {
const parts: Array<{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }> =
[]
if (values.file) {
const resolved = path.resolve(process.cwd(), values.file)
const file = Bun.file(resolved)
if (!(await file.exists())) {
console.error(`Error: file not found: ${values.file}`)
process.exit(1)
}
parts.push({
type: "file",
url: pathToFileURL(resolved).href,
filename: path.basename(resolved),
mime: "text/plain",
})
}
parts.push({ type: "text", text: message })
const session = await opencode.client.session.create()
const result = await opencode.client.session
.prompt({
path: { id: session.data!.id },
body: {
agent: "duplicate-issue",
parts,
},
signal: AbortSignal.timeout(120_000),
})
.then((x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? "")
console.log(result.trim())
} finally {
opencode.server.close()
}
}
main()