Compare commits

...

60 Commits

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

View File

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

View File

@@ -6,19 +6,9 @@ on:
jobs:
check-standards:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check PR standards
@@ -26,6 +16,22 @@ jobs:
with:
script: |
const pr = context.payload.pull_request;
const login = pr.user.login;
// 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 +143,193 @@ 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;
// 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

@@ -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

@@ -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

@@ -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

@@ -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(
@@ -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

@@ -7,7 +7,7 @@
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json ./.output/public/tui.json",
"start": "vite start"
},
"dependencies": {

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

@@ -2,46 +2,62 @@
import { z } from "zod"
import { Config } from "../src/config/config"
import { TuiConfig } from "../src/config/tui"
const file = process.argv[2]
console.log(file)
function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
const result = z.toJSONSchema(Config.Info, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
// Preserve strictness: set additionalProperties: false for objects
if (schema && typeof schema === "object" && schema.type === "object" && schema.additionalProperties === undefined) {
schema.additionalProperties = false
}
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
// Preserve strictness: set additionalProperties: false for objects
if (
schema &&
typeof schema === "object" &&
schema.type === "object" &&
schema.additionalProperties === undefined
) {
schema.additionalProperties = false
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
}
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
return result
}
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
const configFile = process.argv[2]
const tuiFile = process.argv[3]
await Bun.write(file, JSON.stringify(result, null, 2))
console.log(configFile)
await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
if (tuiFile) {
console.log(tuiFile)
await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2))
}

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

@@ -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

@@ -38,6 +38,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -104,6 +106,7 @@ import type { EventSource } from "./context/sdk"
export function tui(input: {
url: string
args: Args
config: TuiConfig.Info
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
@@ -138,35 +141,37 @@ export function tui(input: {
<KVProvider>
<ToastProvider>
<RouteProvider>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>

View File

@@ -2,6 +2,9 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -63,8 +66,13 @@ export const AttachCommand = cmd({
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const config = await Instance.provide({
directory: directory && existsSync(directory) ? directory : process.cwd(),
fn: () => TuiConfig.get(),
})
await tui({
url: args.url,
config,
args: {
continue: args.continue,
sessionID: args.session,

View File

@@ -80,11 +80,11 @@ const TIPS = [
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model",
"Override any keybind in config via the {highlight}keybinds{/highlight} section",
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
@@ -140,7 +140,7 @@ const TIPS = [
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",

View File

@@ -1,5 +1,4 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
@@ -7,14 +6,15 @@ import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { useTuiConfig } from "./tui-config"
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const sync = useSync()
const keybinds = createMemo(() => {
const config = useTuiConfig()
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
return pipe(
sync.data.config.keybinds ?? {},
(config.keybinds ?? {}) as Record<string, string>,
mapValues((value) => Keybind.parse(value)),
)
})

View File

@@ -1,7 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import aura from "./theme/aura.json" with { type: "json" }
import ayu from "./theme/ayu.json" with { type: "json" }
@@ -41,6 +40,7 @@ import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
type ThemeColors = {
primary: RGBA
@@ -279,17 +279,17 @@ function ansiToRgba(code: number): RGBA {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
const sync = useSync()
const config = useTuiConfig()
const kv = useKV()
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
createEffect(() => {
const theme = sync.data.config.theme
const theme = config.theme
if (theme) setStore("active", theme)
})

View File

@@ -0,0 +1,9 @@
import { TuiConfig } from "@/config/tui"
import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
name: "TuiConfig",
init: (props: { config: TuiConfig.Info }) => {
return props.config
},
})

View File

@@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
addDefaultParsers(parsers.parsers)
@@ -100,6 +101,7 @@ const context = createContext<{
showDetails: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig>
}>()
function use() {
@@ -112,6 +114,7 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
@@ -164,7 +167,7 @@ export function Session() {
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
const tui = sync.data.config.tui
const tui = tuiConfig
if (tui?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
@@ -976,6 +979,7 @@ export function Session() {
showDetails,
diffWrapMode,
sync,
tui: tuiConfig,
}}
>
<box flexDirection="row">
@@ -1920,7 +1924,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
const { theme, syntax } = useTheme()
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
@@ -1991,7 +1995,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const files = createMemo(() => props.metadata.files ?? [])
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified"
})

View File

@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
type PermissionStage = "permission" | "always" | "reject"
@@ -48,14 +49,14 @@ function EditBody(props: { request: PermissionRequest }) {
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
const sync = useSync()
const config = useTuiConfig()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
const view = createMemo(() => {
const diffStyle = sync.data.config.tui?.diff_style
const diffStyle = config.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})

View File

@@ -3,13 +3,17 @@ import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -99,7 +103,7 @@ export const TuiThreadCommand = cmd({
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const workerPath = await iife(async () => {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
if (await Bun.file(distWorker).exists()) return distWorker
if (await Filesystem.exists(fileURLToPath(distWorker))) return distWorker
return localWorker
})
try {
@@ -133,6 +137,10 @@ export const TuiThreadCommand = cmd({
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})
const config = await Instance.provide({
directory: cwd,
fn: () => TuiConfig.get(),
})
// Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args)
@@ -161,6 +169,8 @@ export const TuiThreadCommand = cmd({
const tuiPromise = tui({
url,
config,
directory: cwd,
fetch: customFetch,
events,
args: {

View File

@@ -3,7 +3,6 @@ import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
@@ -32,6 +31,8 @@ import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Control } from "@/control"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -40,7 +41,7 @@ export namespace Config {
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
// These settings override all user and project settings
function getManagedConfigDir(): string {
function systemManagedConfigDir(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/opencode"
@@ -51,10 +52,14 @@ export namespace Config {
}
}
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
export function managedConfigDir() {
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
}
const managedDir = managedConfigDir()
// Custom merge function that concatenates array fields instead of replacing them
function merge(target: Info, source: Info): Info {
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@@ -89,7 +94,10 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = merge(result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`))
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`),
)
log.debug("loaded remote config from well-known", { url: key })
}
}
@@ -99,21 +107,18 @@ export namespace Config {
}
// Global user config overrides remote config.
result = merge(result, await global())
result = mergeConfigConcatArrays(result, await global())
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
// Project config overrides global and remote config.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
result = merge(result, await loadFile(resolved))
}
for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
result = mergeConfigConcatArrays(result, await loadFile(file))
}
}
@@ -121,31 +126,10 @@ export namespace Config {
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = [
Global.Path.config,
// Only scan project .opencode/ directories when project discovery is enabled
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Instance.directory,
stop: Instance.worktree,
}),
)
: []),
// Always scan ~/.opencode/ (user home directory)
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
]
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
// .opencode directory config overrides (project and global) config sources.
if (Flag.OPENCODE_CONFIG_DIR) {
directories.push(Flag.OPENCODE_CONFIG_DIR)
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
@@ -155,7 +139,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = merge(result, await loadFile(path.join(dir, file)))
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
@@ -178,7 +162,7 @@ export namespace Config {
// Inline config content overrides all non-managed config sources.
if (Flag.OPENCODE_CONFIG_CONTENT) {
result = merge(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
@@ -186,9 +170,9 @@ export namespace Config {
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions
// This way it only loads config file and not skills/plugins/commands
if (existsSync(managedConfigDir)) {
if (existsSync(managedDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
result = merge(result, await loadFile(path.join(managedConfigDir, file)))
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
}
}
@@ -227,8 +211,6 @@ export namespace Config {
result.share = "auto"
}
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
// Apply flag overrides for compaction settings
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
@@ -291,7 +273,7 @@ export namespace Config {
}
}
async function needsInstall(dir: string) {
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
@@ -918,20 +900,6 @@ export namespace Config {
ref: "KeybindsConfig",
})
export const TUI = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -1006,10 +974,7 @@ export namespace Config {
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
logLevel: Log.Level.optional().describe("Log level"),
tui: TUI.optional().describe("TUI specific settings"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), Command)
@@ -1229,82 +1194,32 @@ export namespace Config {
return result
})
export const { readFile } = ConfigPaths
async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath })
let text = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
const text = await readFile(filepath)
if (!text) return {}
return load(text, filepath)
}
async function load(text: string, configFilepath: string) {
const original = text
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const data = await ConfigPaths.parseText(text, configFilepath)
const fileMatches = text.match(/\{file:[^}]+\}/g)
if (fileMatches) {
const configDir = path.dirname(configFilepath)
const lines = text.split("\n")
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: configFilepath })
return copy
})()
for (const match of fileMatches) {
const lineIndex = lines.findIndex((line) => line.includes(match))
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
continue // Skip if line is commented
}
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: any) => {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configFilepath,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
})
).trim()
// escape newlines/quotes, strip outer quotes
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
}
}
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configFilepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
const parsed = Info.safeParse(data)
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema) {
parsed.data.$schema = "https://opencode.ai/config.json"
@@ -1329,13 +1244,7 @@ export namespace Config {
issues: parsed.error.issues,
})
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const { JsonError, InvalidError } = ConfigPaths
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
@@ -1346,15 +1255,6 @@ export namespace Config {
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
export async function get() {
return state().then((x) => x.config)
}

View File

@@ -0,0 +1,155 @@
import path from "path"
import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda"
import z from "zod"
import { ConfigPaths } from "./paths"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Global } from "@/global"
const log = Log.create({ service: "tui.migrate" })
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const LegacyTheme = TuiInfo.shape.theme.optional()
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
const TuiLegacy = z
.object({
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
diff_style: TuiOptions.shape.diff_style.catch(undefined),
})
.strip()
interface MigrateInput {
directories: string[]
custom?: string
managed: string
}
/**
* Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
* into dedicated tui.json files. Migration is performed per-directory and
* skips only locations where a tui.json already exists.
*/
export async function migrateTuiConfig(input: MigrateInput) {
const opencode = await opencodeFiles(input)
for (const file of opencode) {
const source = await Bun.file(file)
.text()
.catch((error) => {
log.warn("failed to read config for tui migration", { path: file, error })
return undefined
})
if (!source) continue
const data = parseJsonc(source)
if (!data || typeof data !== "object" || Array.isArray(data)) continue
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
const extracted = {
theme: theme.success ? theme.data : undefined,
keybinds: keybinds.success ? keybinds.data : undefined,
tui: legacyTui.success ? legacyTui.data : undefined,
}
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
const target = path.join(path.dirname(file), "tui.json")
const targetExists = await Bun.file(target).exists()
if (targetExists) continue
const payload: Record<string, unknown> = {
$schema: TUI_SCHEMA_URL,
}
if (extracted.theme !== undefined) payload.theme = extracted.theme
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
return false
})
if (!wrote) continue
const stripped = await backupAndStripLegacy(file, source)
if (!stripped) {
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
continue
}
log.info("migrated tui config", { from: file, to: target })
}
}
function normalizeTui(data: Record<string, unknown>) {
const parsed = TuiLegacy.parse(data)
if (
parsed.scroll_speed === undefined &&
parsed.diff_style === undefined &&
parsed.scroll_acceleration === undefined
) {
return
}
return parsed
}
async function backupAndStripLegacy(file: string, source: string) {
const backup = file + ".tui-migration.bak"
const hasBackup = await Bun.file(backup).exists()
const backed = hasBackup
? true
: await Bun.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
return false
})
if (!backed) return false
const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
const edits = modify(acc, [key], undefined, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
},
})
if (!edits.length) return acc
return applyEdits(acc, edits)
}, source)
return Bun.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true
})
.catch((error) => {
log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
return false
})
}
async function opencodeFiles(input: { directories: string[]; managed: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
const existing = await Promise.all(
unique(files).map(async (file) => {
const ok = await Bun.file(file).exists()
return ok ? file : undefined
}),
)
return existing.filter((file): file is string => !!file)
}

View File

@@ -0,0 +1,154 @@
import path from "path"
import os from "os"
import z from "zod"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { NamedError } from "@opencode-ai/util/error"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) {
const files: string[] = []
for (const file of [`${name}.jsonc`, `${name}.json`]) {
const found = await Filesystem.findUp(file, directory, worktree)
for (const resolved of found.toReversed()) {
files.push(resolved)
}
}
return files
}
export async function directories(directory: string, worktree: string) {
return [
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: directory,
stop: worktree,
}),
)
: []),
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
]
}
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Bun.file(filepath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, configFilepath: string, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = text.match(/\{file:[^}]+\}/g)
if (!fileMatches) return text
const configDir = path.dirname(configFilepath)
const lines = text.split("\n")
for (const match of fileMatches) {
const lineIndex = lines.findIndex((line) => line.includes(match))
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) continue
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Bun.file(resolvedPath)
.text()
.catch((error) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configFilepath,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
})
).trim()
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
}
return text
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, configFilepath: string, missing: "error" | "empty" = "error") {
text = await substitute(text, configFilepath, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configFilepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}
}

View File

@@ -0,0 +1,25 @@
import z from "zod"
import { Config } from "./config"
export const TuiOptions = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const TuiInfo = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: Config.Keybinds.optional(),
})
.extend(TuiOptions.shape)
.strict()

View File

@@ -0,0 +1,118 @@
import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./migrate-tui-config"
import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Global } from "@/global"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
export type Info = z.output<typeof Info>
function mergeInfo(target: Info, source: Info): Info {
return mergeDeep(target, source)
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
const state = Instance.state(async () => {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
const custom = customPath()
const managed = Config.managedConfigDir()
await migrateTuiConfig({ directories, custom, managed })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
let result: Info = {}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
result = mergeInfo(result, await loadFile(file))
}
if (custom) {
result = mergeInfo(result, await loadFile(custom))
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
result = mergeInfo(result, await loadFile(file))
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
result = mergeInfo(result, await loadFile(file))
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
result = mergeInfo(result, await loadFile(file))
}
}
result.keybinds ??= Config.Keybinds.parse({})
return {
config: result,
}
})
export async function get() {
return state().then((x) => x.config)
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = (() => {
const copy = { ...(data as Record<string, unknown>) }
if (!("tui" in copy)) return copy
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
delete copy.tui
return copy
}
const tui = copy.tui as Record<string, unknown>
delete copy.tui
return {
...tui,
...copy,
}
})()
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
return {}
}
return parsed.data
}
}

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

@@ -7,6 +7,7 @@ export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
@@ -74,6 +75,17 @@ Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", {
configurable: false,
})
// Dynamic getter for OPENCODE_TUI_CONFIG
// This must be evaluated at access time, not module load time,
// because tests and external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_TUI_CONFIG", {
get() {
return process.env["OPENCODE_TUI_CONFIG"]
},
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CONFIG_DIR
// This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime

View File

@@ -147,8 +147,7 @@ export namespace LSPClient {
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
const file = Bun.file(input.path)
const text = await file.text()
const text = await Filesystem.readText(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"

View File

@@ -131,7 +131,7 @@ export namespace LSPServer {
"bin",
"vue-language-server.js",
)
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
@@ -173,14 +173,14 @@ export namespace LSPServer {
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
if (!(await Bun.file(serverPath).exists())) {
if (!(await Filesystem.exists(serverPath))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("downloading and building VS Code ESLint server")
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip")
await Bun.file(zipPath).write(response)
if (response.body) await Filesystem.writeStream(zipPath, response.body)
const ok = await Archive.extractZip(zipPath, Global.Path.bin)
.then(() => true)
@@ -242,7 +242,7 @@ export namespace LSPServer {
const resolveBin = async (target: string) => {
const localBin = path.join(root, target)
if (await Bun.file(localBin).exists()) return localBin
if (await Filesystem.exists(localBin)) return localBin
const candidates = Filesystem.up({
targets: [target],
@@ -326,7 +326,7 @@ export namespace LSPServer {
async spawn(root) {
const localBin = path.join(root, "node_modules", ".bin", "biome")
let bin: string | undefined
if (await Bun.file(localBin).exists()) bin = localBin
if (await Filesystem.exists(localBin)) bin = localBin
if (!bin) {
const found = Bun.which("biome")
if (found) bin = found
@@ -467,7 +467,7 @@ export namespace LSPServer {
const potentialPythonPath = isWindows
? path.join(venvPath, "Scripts", "python.exe")
: path.join(venvPath, "bin", "python")
if (await Bun.file(potentialPythonPath).exists()) {
if (await Filesystem.exists(potentialPythonPath)) {
initialization["pythonPath"] = potentialPythonPath
break
}
@@ -479,7 +479,7 @@ export namespace LSPServer {
const potentialTyPath = isWindows
? path.join(venvPath, "Scripts", "ty.exe")
: path.join(venvPath, "bin", "ty")
if (await Bun.file(potentialTyPath).exists()) {
if (await Filesystem.exists(potentialTyPath)) {
binary = potentialTyPath
break
}
@@ -511,7 +511,7 @@ export namespace LSPServer {
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
@@ -536,7 +536,7 @@ export namespace LSPServer {
const potentialPythonPath = isWindows
? path.join(venvPath, "Scripts", "python.exe")
: path.join(venvPath, "bin", "python")
if (await Bun.file(potentialPythonPath).exists()) {
if (await Filesystem.exists(potentialPythonPath)) {
initialization["pythonPath"] = potentialPythonPath
break
}
@@ -571,7 +571,7 @@ export namespace LSPServer {
process.platform === "win32" ? "language_server.bat" : "language_server.sh",
)
if (!(await Bun.file(binary).exists())) {
if (!(await Filesystem.exists(binary))) {
const elixir = Bun.which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
@@ -584,7 +584,7 @@ export namespace LSPServer {
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
if (!response.ok) return
const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
await Bun.file(zipPath).write(response)
if (response.body) await Filesystem.writeStream(zipPath, response.body)
const ok = await Archive.extractZip(zipPath, Global.Path.bin)
.then(() => true)
@@ -692,7 +692,7 @@ export namespace LSPServer {
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
if (ext === "zip") {
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
@@ -710,7 +710,7 @@ export namespace LSPServer {
bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract zls binary")
return
}
@@ -857,7 +857,7 @@ export namespace LSPServer {
// Stop at filesystem root
const cargoTomlPath = path.join(currentDir, "Cargo.toml")
try {
const cargoTomlContent = await Bun.file(cargoTomlPath).text()
const cargoTomlContent = await Filesystem.readText(cargoTomlPath)
if (cargoTomlContent.includes("[workspace]")) {
return currentDir
}
@@ -907,7 +907,7 @@ export namespace LSPServer {
const ext = process.platform === "win32" ? ".exe" : ""
const direct = path.join(Global.Path.bin, "clangd" + ext)
if (await Bun.file(direct).exists()) {
if (await Filesystem.exists(direct)) {
return {
process: spawn(direct, args, {
cwd: root,
@@ -920,7 +920,7 @@ export namespace LSPServer {
if (!entry.isDirectory()) continue
if (!entry.name.startsWith("clangd_")) continue
const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext)
if (await Bun.file(candidate).exists()) {
if (await Filesystem.exists(candidate)) {
return {
process: spawn(candidate, args, {
cwd: root,
@@ -990,7 +990,7 @@ export namespace LSPServer {
log.error("Failed to write clangd archive")
return
}
await Bun.write(archive, buf)
await Filesystem.write(archive, Buffer.from(buf))
const zip = name.endsWith(".zip")
const tar = name.endsWith(".tar.xz")
@@ -1014,7 +1014,7 @@ export namespace LSPServer {
await fs.rm(archive, { force: true })
const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext)
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract clangd binary")
return
}
@@ -1045,7 +1045,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
@@ -1092,7 +1092,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
@@ -1248,7 +1248,7 @@ export namespace LSPServer {
const distPath = path.join(Global.Path.bin, "kotlin-ls")
const launcherScript =
process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh")
const installed = await Bun.file(launcherScript).exists()
const installed = await Filesystem.exists(launcherScript)
if (!installed) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("Downloading Kotlin Language Server from GitHub.")
@@ -1307,7 +1307,7 @@ export namespace LSPServer {
}
log.info("Installed Kotlin Language Server", { path: launcherScript })
}
if (!(await Bun.file(launcherScript).exists())) {
if (!(await Filesystem.exists(launcherScript))) {
log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`)
return
}
@@ -1336,7 +1336,7 @@ export namespace LSPServer {
"src",
"server.js",
)
const exists = await Bun.file(js).exists()
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
@@ -1443,7 +1443,7 @@ export namespace LSPServer {
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
// Unlike zls which is a single self-contained binary,
// lua-language-server needs supporting files (meta/, locale/, etc.)
@@ -1482,7 +1482,7 @@ export namespace LSPServer {
// Binary is located in bin/ subdirectory within the extracted archive
bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract lua-language-server binary")
return
}
@@ -1516,7 +1516,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
@@ -1613,7 +1613,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
@@ -1654,22 +1654,17 @@ export namespace LSPServer {
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("downloading terraform-ls from GitHub releases")
log.info("downloading terraform-ls from HashiCorp releases")
const releaseResponse = await fetch("https://api.github.com/repos/hashicorp/terraform-ls/releases/latest")
const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest")
if (!releaseResponse.ok) {
log.error("Failed to fetch terraform-ls release info")
return
}
const release = (await releaseResponse.json()) as {
tag_name?: string
assets?: { name?: string; browser_download_url?: string }[]
}
const version = release.tag_name?.replace("v", "")
if (!version) {
log.error("terraform-ls release did not include a version tag")
return
version?: string
builds?: { arch?: string; os?: string; url?: string }[]
}
const platform = process.platform
@@ -1678,23 +1673,21 @@ export namespace LSPServer {
const tfArch = arch === "arm64" ? "arm64" : "amd64"
const tfPlatform = platform === "win32" ? "windows" : platform
const assetName = `terraform-ls_${version}_${tfPlatform}_${tfArch}.zip`
const assets = release.assets ?? []
const asset = assets.find((a) => a.name === assetName)
if (!asset?.browser_download_url) {
log.error(`Could not find asset ${assetName} in terraform-ls release`)
const builds = release.builds ?? []
const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform)
if (!build?.url) {
log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`)
return
}
const downloadResponse = await fetch(asset.browser_download_url)
const downloadResponse = await fetch(build.url)
if (!downloadResponse.ok) {
log.error("Failed to download terraform-ls")
return
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
const tempPath = path.join(Global.Path.bin, "terraform-ls.zip")
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
.then(() => true)
@@ -1707,7 +1700,7 @@ export namespace LSPServer {
bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract terraform-ls binary")
return
}
@@ -1784,7 +1777,7 @@ export namespace LSPServer {
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
if (ext === "zip") {
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
@@ -1803,7 +1796,7 @@ export namespace LSPServer {
bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract texlab binary")
return
}
@@ -1832,7 +1825,7 @@ export namespace LSPServer {
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Bun.file(js).exists())) {
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
@@ -1990,7 +1983,7 @@ export namespace LSPServer {
}
const tempPath = path.join(Global.Path.bin, assetName)
await Bun.file(tempPath).write(downloadResponse)
if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
if (ext === "zip") {
const ok = await Archive.extractZip(tempPath, Global.Path.bin)
@@ -2008,7 +2001,7 @@ export namespace LSPServer {
bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : ""))
if (!(await Bun.file(bin).exists())) {
if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract tinymist binary")
return
}

View File

@@ -5,6 +5,7 @@ import z from "zod"
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "../util/filesystem"
// Try to import bundled snapshot (generated at build time)
// Falls back to undefined in dev mode when snapshot doesn't exist
@@ -85,8 +86,7 @@ export namespace ModelsDev {
}
export const Data = lazy(async () => {
const file = Bun.file(Flag.OPENCODE_MODELS_PATH ?? filepath)
const result = await file.json().catch(() => {})
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
if (result) return result
// @ts-ignore
const snapshot = await import("./models-snapshot")
@@ -104,7 +104,6 @@ export namespace ModelsDev {
}
export async function refresh() {
const file = Bun.file(filepath)
const result = await fetch(`${url()}/api.json`, {
headers: {
"User-Agent": Installation.USER_AGENT,
@@ -116,7 +115,7 @@ export namespace ModelsDev {
})
})
if (result && result.ok) {
await Bun.write(file, await result.text())
await Filesystem.write(filepath, await result.text())
ModelsDev.Data.reset()
}
}

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

@@ -1,8 +1,10 @@
import { mkdir, readFile, writeFile } from "fs/promises"
import { existsSync, statSync } from "fs"
import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types"
import { realpathSync } from "fs"
import { dirname, join, relative } from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
export namespace Filesystem {
// Fast sync version for metadata checks
@@ -18,12 +20,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> {
@@ -67,6 +70,25 @@ export namespace Filesystem {
return write(p, JSON.stringify(data, null, 2), mode)
}
export async function writeStream(
p: string,
stream: ReadableStream<Uint8Array> | Readable,
mode?: number,
): Promise<void> {
const dir = dirname(p)
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true })
}
const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream
const writeStream = createWriteStream(p)
await pipeline(nodeStream, writeStream)
if (mode) {
await chmod(p, mode)
}
}
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}

View File

@@ -1,5 +1,6 @@
import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
import { Global } from "../global"
import z from "zod"
@@ -63,13 +64,15 @@ export namespace Log {
Global.Path.log,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const logfile = Bun.file(logpath)
await fs.truncate(logpath).catch(() => {})
const writer = logfile.writer()
const stream = createWriteStream(logpath, { flags: "a" })
write = async (msg: any) => {
const num = writer.write(msg)
writer.flush()
return num
return new Promise((resolve, reject) => {
stream.write(msg, (err) => {
if (err) reject(err)
else resolve(msg.length)
})
})
}
}

View File

@@ -55,6 +55,28 @@ test("loads JSON config file", async () => {
})
})
test("ignores legacy tui keys in opencode config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
model: "test/model",
theme: "legacy",
tui: { scroll_speed: 4 },
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.model).toBe("test/model")
expect((config as Record<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).tui).toBeUndefined()
},
})
})
test("loads JSONC config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -109,14 +131,14 @@ test("merges multiple config files with correct precedence", async () => {
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
process.env["TEST_VAR"] = "test_theme"
process.env["TEST_VAR"] = "test-user"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
theme: "{env:TEST_VAR}",
username: "{env:TEST_VAR}",
})
},
})
@@ -124,7 +146,7 @@ test("handles environment variable substitution", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_theme")
expect(config.username).toBe("test-user")
},
})
} finally {
@@ -147,7 +169,7 @@ test("preserves env variables when adding $schema to config", async () => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
theme: "{env:PRESERVE_VAR}",
username: "{env:PRESERVE_VAR}",
}),
)
},
@@ -156,7 +178,7 @@ test("preserves env variables when adding $schema to config", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("secret_value")
expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = await Bun.file(path.join(tmp.path, "opencode.json")).text()
@@ -177,10 +199,10 @@ test("preserves env variables when adding $schema to config", async () => {
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "included.txt"), "test_theme")
await Bun.write(path.join(dir, "included.txt"), "test-user")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
theme: "{file:included.txt}",
username: "{file:included.txt}",
})
},
})
@@ -188,7 +210,7 @@ test("handles file inclusion substitution", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_theme")
expect(config.username).toBe("test-user")
},
})
})
@@ -199,7 +221,7 @@ test("handles file inclusion with replacement tokens", async () => {
await Bun.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
theme: "{file:included.md}",
username: "{file:included.md}",
})
},
})
@@ -207,7 +229,7 @@ test("handles file inclusion with replacement tokens", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("const out = await Bun.$`echo hi`")
expect(config.username).toBe("const out = await Bun.$`echo hi`")
},
})
})
@@ -1042,7 +1064,6 @@ test("managed settings override project settings", async () => {
$schema: "https://opencode.ai/config.json",
autoupdate: true,
disabled_providers: [],
theme: "dark",
})
},
})
@@ -1059,7 +1080,6 @@ test("managed settings override project settings", async () => {
const config = await Config.get()
expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"])
expect(config.theme).toBe("dark")
},
})
})

View File

@@ -0,0 +1,439 @@
import { afterEach, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { TuiConfig } from "../../src/config/tui"
import { Global } from "../../src/global"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
afterEach(async () => {
delete process.env.OPENCODE_CONFIG
delete process.env.OPENCODE_TUI_CONFIG
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
})
test("loads tui config with the same precedence order as server config paths", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(
path.join(dir, ".opencode", "tui.json"),
JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("local")
expect(config.diff_style).toBe("stacked")
},
})
})
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "migrated-theme",
tui: { scroll_speed: 5 },
keybinds: { app_exit: "ctrl+q" },
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(5)
expect(config.keybinds?.app_exit).toBe("ctrl+q")
const text = await Bun.file(path.join(tmp.path, "tui.json")).text()
expect(JSON.parse(text)).toMatchObject({
theme: "migrated-theme",
scroll_speed: 5,
})
const server = JSON.parse(await Bun.file(path.join(tmp.path, "opencode.json")).text())
expect(server.theme).toBeUndefined()
expect(server.keybinds).toBeUndefined()
expect(server.tui).toBeUndefined()
expect(await Bun.file(path.join(tmp.path, "opencode.json.tui-migration.bak")).exists()).toBe(true)
expect(await Bun.file(path.join(tmp.path, "tui.json")).exists()).toBe(true)
},
})
})
test("migrates project legacy tui keys even when global tui.json already exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "project-migrated",
tui: { scroll_speed: 2 },
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("project-migrated")
expect(config.scroll_speed).toBe(2)
expect(await Bun.file(path.join(tmp.path, "tui.json")).exists()).toBe(true)
const server = JSON.parse(await Bun.file(path.join(tmp.path, "opencode.json")).text())
expect(server.theme).toBeUndefined()
expect(server.tui).toBeUndefined()
},
})
})
test("drops unknown legacy tui keys during migration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "migrated-theme",
tui: { scroll_speed: 2, foo: 1 },
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(2)
const text = await Bun.file(path.join(tmp.path, "tui.json")).text()
const migrated = JSON.parse(text)
expect(migrated.scroll_speed).toBe(2)
expect(migrated.foo).toBeUndefined()
},
})
})
test("skips migration when tui.json already exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "legacy" }, null, 2))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("stacked")
expect(config.theme).toBeUndefined()
const server = JSON.parse(await Bun.file(path.join(tmp.path, "opencode.json")).text())
expect(server.theme).toBe("legacy")
expect(await Bun.file(path.join(tmp.path, "opencode.json.tui-migration.bak")).exists()).toBe(false)
},
})
})
test("continues loading tui config when legacy source cannot be stripped", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2))
},
})
const source = path.join(tmp.path, "opencode.json")
await fs.chmod(source, 0o444)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("readonly-theme")
expect(await Bun.file(path.join(tmp.path, "tui.json")).exists()).toBe(true)
const server = JSON.parse(await Bun.file(source).text())
expect(server.theme).toBe("readonly-theme")
},
})
} finally {
await fs.chmod(source, 0o644)
}
})
test("migration backup preserves JSONC comments", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.jsonc"),
`{
// top-level comment
"theme": "jsonc-theme",
"tui": {
// nested comment
"scroll_speed": 1.5
}
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await TuiConfig.get()
const backup = await Bun.file(path.join(tmp.path, "opencode.jsonc.tui-migration.bak")).text()
expect(backup).toContain("// top-level comment")
expect(backup).toContain("// nested comment")
expect(backup).toContain('"theme": "jsonc-theme"')
expect(backup).toContain('"scroll_speed": 1.5')
},
})
})
test("migrates legacy tui keys across multiple opencode.json levels", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const nested = path.join(dir, "apps", "client")
await fs.mkdir(nested, { recursive: true })
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "root-theme" }, null, 2))
await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
},
})
await Instance.provide({
directory: path.join(tmp.path, "apps", "client"),
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("nested-theme")
expect(await Bun.file(path.join(tmp.path, "tui.json")).exists()).toBe(true)
expect(await Bun.file(path.join(tmp.path, "apps", "client", "tui.json")).exists()).toBe(true)
},
})
})
test("flattens nested tui key inside tui.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
theme: "outer",
tui: { scroll_speed: 3, diff_style: "stacked" },
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.scroll_speed).toBe(3)
expect(config.diff_style).toBe("stacked")
// top-level keys take precedence over nested tui keys
expect(config.theme).toBe("outer")
},
})
})
test("top-level keys in tui.json take precedence over nested tui key", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
diff_style: "auto",
tui: { diff_style: "stacked", scroll_speed: 2 },
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("auto")
expect(config.scroll_speed).toBe(2)
},
})
})
test("OPENCODE_TUI_CONFIG takes precedence over project config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" }))
const custom = path.join(dir, "custom-tui.json")
await Bun.write(custom, JSON.stringify({ theme: "custom", diff_style: "stacked" }))
process.env.OPENCODE_TUI_CONFIG = custom
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
// project tui.json overrides the custom path (higher precedence)
expect(config.theme).toBe("project")
// but project also set diff_style, so that wins
expect(config.diff_style).toBe("auto")
},
})
})
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const custom = path.join(dir, "custom-tui.json")
await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" }))
process.env.OPENCODE_TUI_CONFIG = custom
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("from-env")
expect(config.diff_style).toBe("stacked")
},
})
})
test("does not derive tui path from OPENCODE_CONFIG", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const customDir = path.join(dir, "custom")
await fs.mkdir(customDir, { recursive: true })
await Bun.write(path.join(customDir, "opencode.json"), JSON.stringify({ model: "test/model" }))
await Bun.write(path.join(customDir, "tui.json"), JSON.stringify({ theme: "should-not-load" }))
process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBeUndefined()
},
})
})
test("applies env and file substitutions in tui.json", async () => {
const original = process.env.TUI_THEME_TEST
process.env.TUI_THEME_TEST = "env-theme"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q")
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
theme: "{env:TUI_THEME_TEST}",
keybinds: { app_exit: "{file:keybind.txt}" },
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("env-theme")
expect(config.keybinds?.app_exit).toBe("ctrl+q")
},
})
} finally {
if (original === undefined) delete process.env.TUI_THEME_TEST
else process.env.TUI_THEME_TEST = original
}
})
test("loads managed tui config and gives it highest precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
await fs.mkdir(managedConfigDir, { recursive: true })
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme")
},
})
})
test("loads .opencode/tui.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("stacked")
},
})
})
test("gracefully falls back when tui.json has invalid JSON", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
await fs.mkdir(managedConfigDir, { recursive: true })
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-fallback")
expect(config.keybinds).toBeDefined()
},
})
})

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

@@ -285,4 +285,125 @@ describe("filesystem", () => {
expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
})
})
describe("writeStream()", () => {
test("writes from Web ReadableStream", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "streamed.txt")
const content = "Hello from stream!"
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(content))
controller.close()
},
})
await Filesystem.writeStream(filepath, stream)
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
test("writes from Node.js Readable stream", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "node-streamed.txt")
const content = "Hello from Node stream!"
const { Readable } = await import("stream")
const stream = Readable.from([content])
await Filesystem.writeStream(filepath, stream)
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
test("writes binary data from Web ReadableStream", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "binary.dat")
const binaryData = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff])
const stream = new ReadableStream({
start(controller) {
controller.enqueue(binaryData)
controller.close()
},
})
await Filesystem.writeStream(filepath, stream)
const read = await fs.readFile(filepath)
expect(Buffer.from(read)).toEqual(Buffer.from(binaryData))
})
test("writes large content in chunks", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "large.txt")
const chunks = ["chunk1", "chunk2", "chunk3", "chunk4", "chunk5"]
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk))
}
controller.close()
},
})
await Filesystem.writeStream(filepath, stream)
expect(await fs.readFile(filepath, "utf-8")).toBe(chunks.join(""))
})
test("creates parent directories", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "deep", "streamed.txt")
const content = "nested stream content"
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(content))
controller.close()
},
})
await Filesystem.writeStream(filepath, stream)
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
test("writes with permissions", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "protected-stream.txt")
const content = "secret stream content"
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(content))
controller.close()
},
})
await Filesystem.writeStream(filepath, stream, 0o600)
const stats = await fs.stat(filepath)
if (process.platform !== "win32") {
expect(stats.mode & 0o777).toBe(0o600)
}
})
test("writes executable with permissions", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "script.sh")
const content = "#!/bin/bash\necho hello"
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(content))
controller.close()
},
})
await Filesystem.writeStream(filepath, stream, 0o755)
const stats = await fs.stat(filepath)
if (process.platform !== "win32") {
expect(stats.mode & 0o777).toBe(0o755)
}
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
})
})

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

@@ -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

@@ -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

@@ -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;
}

View File

@@ -286,18 +286,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 +302,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,12 +317,7 @@ 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}>

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-4)",
"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"
}
}
}

View File

@@ -558,6 +558,7 @@ OpenCode can be configured using environment variables.
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
| `OPENCODE_CONFIG` | string | Path to config file |
| `OPENCODE_TUI_CONFIG` | string | Path to TUI config file |
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |

View File

@@ -14,10 +14,11 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
// Theme configuration
"theme": "opencode",
"model": "anthropic/claude-sonnet-4-5",
"autoupdate": true,
"server": {
"port": 4096,
},
}
```
@@ -34,7 +35,7 @@ Configuration files are **merged together**, not replaced.
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
For example, if your global config sets `autoupdate: true` and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include both settings.
---
@@ -95,7 +96,9 @@ You can enable specific servers in your local config:
### Global
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds.
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide server/runtime preferences like providers, models, and permissions.
For TUI-specific settings, use `~/.config/opencode/tui.json`.
Global config overrides remote organizational defaults.
@@ -105,6 +108,8 @@ Global config overrides remote organizational defaults.
Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs.
For project-specific TUI settings, add `tui.json` alongside it.
:::tip
Place project specific config in the root of your project.
:::
@@ -146,7 +151,9 @@ The custom directory is loaded after the global config and `.opencode` directori
## Schema
The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
TUI config uses [**`opencode.ai/tui.json`**](https://opencode.ai/tui.json).
Your editor should be able to validate and autocomplete based on the schema.
@@ -154,28 +161,24 @@ Your editor should be able to validate and autocomplete based on the schema.
### TUI
You can configure TUI-specific settings through the `tui` option.
Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
}
"$schema": "https://opencode.ai/tui.json",
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
}
```
Available options:
Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
[Learn more about using the TUI here](/docs/tui).
[Learn more about TUI configuration here](/docs/tui#configure).
---
@@ -301,12 +304,12 @@ Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over pr
### Themes
You can configure the theme you want to use in your OpenCode config through the `theme` option.
Set your UI theme in `tui.json`.
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"theme": ""
"$schema": "https://opencode.ai/tui.json",
"theme": "tokyonight"
}
```
@@ -406,11 +409,11 @@ You can also define commands using markdown files in `~/.config/opencode/command
### Keybinds
You can customize your keybinds through the `keybinds` option.
Customize keybinds in `tui.json`.
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://opencode.ai/tui.json",
"keybinds": {}
}
```

View File

@@ -3,11 +3,11 @@ title: Keybinds
description: Customize your keybinds.
---
OpenCode has a list of keybinds that you can customize through the OpenCode config.
OpenCode has a list of keybinds that you can customize through `tui.json`.
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://opencode.ai/tui.json",
"keybinds": {
"leader": "ctrl+x",
"app_exit": "ctrl+c,ctrl+d,<leader>q",
@@ -117,11 +117,11 @@ You don't need to use a leader key for your keybinds but we recommend doing so.
## Disable keybind
You can disable a keybind by adding the key to your config with a value of "none".
You can disable a keybind by adding the key to `tui.json` with a value of "none".
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://opencode.ai/tui.json",
"keybinds": {
"session_compact": "none"
}

View File

@@ -61,11 +61,11 @@ The system theme is for users who:
## Using a theme
You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in your [config](/docs/config).
You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in `tui.json`.
```json title="opencode.json" {3}
```json title="tui.json" {3}
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://opencode.ai/tui.json",
"theme": "tokyonight"
}
```

View File

@@ -355,24 +355,34 @@ Some editors need command-line arguments to run in blocking mode. The `--wait` f
## Configure
You can customize TUI behavior through your OpenCode config file.
You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
}
}
"$schema": "https://opencode.ai/tui.json",
"theme": "opencode",
"keybinds": {
"leader": "ctrl+x"
},
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
}
```
This is separate from `opencode.json`, which configures server/runtime behavior.
### Options
- `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `theme` - Sets your UI theme. [Learn more](/docs/themes).
- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
---