fix(cli): resolve merge conflicts with main

This commit is contained in:
Alex Alecu
2026-04-14 19:26:28 +03:00
3296 changed files with 427791 additions and 111053 deletions
+25
View File
@@ -0,0 +1,25 @@
# Changesets
This directory contains changeset files used to track changes for the next release.
## Adding a changeset
When making a user-facing change, run:
```sh
bunx changeset add
```
Or manually create a file `.changeset/<slug>.md`:
```md
---
"kilo-code": minor
---
Short description of the change for the changelog.
```
Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes.
Changeset files are consumed at release time when the `publish.yml` workflow runs, generating changelog entries for the GitHub release notes.
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.4/schema.json",
"changelog": ["../script/changelog-github.cjs", { "repo": "Kilo-Org/kilocode" }],
"commit": false,
"fixed": [["kilo-code", "@kilocode/cli"]],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Fixed default model falling back to the free model after login or org switch by invalidating cached provider state when auth changes.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-gateway": patch
---
Include the feature header when requesting the models list from Kilo Gateway
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Reduce git process load via visibility-aware polling and resolution caching in GitStatsPoller
+10
View File
@@ -0,0 +1,10 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Merged upstream opencode changes from v1.3.10:
- Subagent tool calls stay clickable while pending
- Improved storage migration reliability
- Better muted text contrast in Catppuccin themes
+9
View File
@@ -0,0 +1,9 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Merged upstream opencode changes from v1.3.6:
- Fixed token usage double-counting for Anthropic and Amazon Bedrock providers
- Fixed variant dialog search filtering
+10
View File
@@ -0,0 +1,10 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Merged upstream opencode changes from v1.3.7:
- Added first-class PowerShell support on Windows
- Plugin installs now preserve JSONC comments in configuration files
- Improved variant modal behavior to be less intrusive
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make subsession costs in TaskHeader tooltip more readable with many subsessions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add @terminal context mention support to the chat input. Type @terminal to include your active VS Code terminal output as context, with output safety limits (500 lines / 50K chars) and truncation. Works in both the sidebar chat and Agent Manager.
+24
View File
@@ -0,0 +1,24 @@
# Enforce LF line endings for all text files
* text=auto eol=lf
# Git LFS tracking for binary/media files
*.gif filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
# Visual regression baseline snapshots
packages/kilo-ui/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
packages/kilo-vscode/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
# Hide non-English localization files by default in GitHub PR diffs.
**/i18n/*.ts linguist-generated=true
**/i18n/en.ts linguist-generated=false
**/i18n/en*.ts linguist-generated=false
**/i18n/package-nls-en.ts linguist-generated=false
**/i18n/index.ts linguist-generated=false
**/i18n/parity.test.ts linguist-generated=false
packages/kilo-i18n/src/*.ts linguist-generated=true
packages/kilo-i18n/src/en.ts linguist-generated=false
# Auto-generated CLI reference docs
packages/kilo-docs/markdoc/partials/cli-commands-table.md linguist-generated=true
packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md linguist-generated=true
+41 -7
View File
@@ -3,17 +3,51 @@ description: "Setup Bun with caching and install dependencies"
runs:
using: "composite"
steps:
- name: Mount Bun Cache
uses: useblacksmith/stickydisk@v1
with:
key: ${{ github.repository }}-bun-cache-${{ runner.os }}
path: ~/.bun
- name: Get baseline download URL
id: bun-url
shell: bash
run: |
if [ "$RUNNER_ARCH" = "X64" ]; then
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
case "$RUNNER_OS" in
macOS) OS=darwin ;;
Linux) OS=linux ;;
Windows) OS=windows ;;
esac
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory
id: cache
shell: bash
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
- name: Cache Bun dependencies
uses: actions/cache@v4
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash
- name: Install dependencies
run: bun install
run: |
# Workaround for patched peer variants
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --linker hoisted
else
bun install
fi
shell: bash
+35
View File
@@ -0,0 +1,35 @@
name: Trigger webhook for feat PRs
on:
pull_request:
types: [closed]
jobs:
call-webhook:
if: >
github.event.pull_request.merged == true &&
(startsWith(github.event.pull_request.title, 'feat:') ||
startsWith(github.event.pull_request.title, 'feat('))
runs-on: ubuntu-latest
steps:
- name: Send webhook safely
run: |
payload=$(jq -n \
--arg repo "${GITHUB_REPOSITORY}" \
--arg pr_number "${{ github.event.pull_request.number }}" \
--arg title "${{ github.event.pull_request.title }}" \
--arg body "${{ github.event.pull_request.body }}" \
--arg author "${{ github.event.pull_request.user.login }}" \
--arg merged_at "${{ github.event.pull_request.merged_at }}" \
'{
repo: $repo,
pr_number: $pr_number,
title: $title,
description: $body,
author: $author,
merged_at: $merged_at
}'
)
curl --fail-with-body -X POST "${{ secrets.DOC_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
--data-raw "$payload"
+1
View File
@@ -7,6 +7,7 @@ on:
jobs:
sync:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
@@ -0,0 +1,32 @@
name: Check opencode annotations
on:
pull_request:
paths:
- "packages/opencode/**"
- "script/check-opencode-annotations.ts"
- ".github/workflows/check-opencode-annotations.yml"
workflow_dispatch:
jobs:
check-annotations:
name: Check kilocode_change annotations
if: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
- name: Check kilocode_change annotations in shared opencode files
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$BASE_SHA" ]; then
bun run script/check-opencode-annotations.ts --base "$BASE_SHA"
else
echo "No PR base SHA available (workflow_dispatch without PR context) — skipping."
fi
+24
View File
@@ -0,0 +1,24 @@
name: close-issues
on:
schedule:
- cron: "0 2 * * *" # Daily at 2:00 AM
workflow_dispatch:
jobs:
close:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Close stale issues
env:
GITHUB_TOKEN: ${{ github.token }}
run: bun script/github/close-issues.ts
+1
View File
@@ -17,6 +17,7 @@ permissions:
jobs:
close-stale-prs:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change
timeout-minutes: 15
steps:
+10
View File
@@ -13,6 +13,7 @@ permissions:
jobs:
close-non-compliant:
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 2 hours
@@ -65,6 +66,15 @@ jobs:
body: closeMessage,
});
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {}
if (isPR) {
await github.rest.pulls.update({
owner: context.repo.owner,
+2 -2
View File
@@ -29,10 +29,10 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Run opencode
uses: Kilo-Org/kilo/github@latest
uses: Kilo-Org/kilocode/github@latest
env:
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
KILO_PERMISSION: '{"bash": "deny"}'
with:
model: kilo/kilo/auto
model: kilo/kilo-auto/frontier
+5 -1
View File
@@ -4,6 +4,9 @@ on:
push:
branches: [main]
pull_request:
paths:
- "packages/kilo-docs/**"
- ".github/workflows/docs-check-links.yml"
schedule:
# Run daily at 9am UTC
- cron: "0 9 * * *"
@@ -11,12 +14,13 @@ on:
jobs:
link-checker:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Link Checker
uses: lycheeverse/lychee-action@v2
uses: Kilo-Org/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411
with:
args: "**/*.md"
workingDirectory: packages/kilo-docs
+66 -1
View File
@@ -2,10 +2,11 @@ name: duplicate-issues
on:
issues:
types: [opened]
types: [opened, edited]
jobs:
check-duplicates:
if: false # kilocode_change - disabled: not needed in kilocode repo
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
@@ -118,3 +119,67 @@ 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
# kilocode_change start
- name: Setup Kilo
uses: ./.github/actions/setup-kilo
# kilocode_change end
- name: Recheck compliance
env:
# kilocode_change start
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
# kilocode_change end
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KILO_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow"
},
"webfetch": "deny"
}
run: |
kilo run -m "kilo/anthropic/claude-haiku-4.5" "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."
+1
View File
@@ -7,6 +7,7 @@ on:
jobs:
generate:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
+96
View File
@@ -0,0 +1,96 @@
name: nix-eval
on:
push:
branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
nix-eval:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Evaluate flake outputs (all systems)
run: |
set -euo pipefail
nix --version
echo "=== Flake metadata ==="
nix flake metadata
echo ""
echo "=== Flake structure ==="
nix flake show --all-systems
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
PACKAGES="kilo"
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
OPTIONAL_PACKAGES="desktop"
echo ""
echo "=== Evaluating packages for all systems ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for packages.$system.$pkg"
echo "$output"
exit 1
fi
done
done
echo ""
echo "=== Evaluating optional packages ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $OPTIONAL_PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::warning::Evaluation failed for packages.$system.$pkg"
echo "$output"
fi
done
done
echo ""
echo "=== Evaluating devShells for all systems ==="
for system in $SYSTEMS; do
printf "%s: " "$system"
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for devShells.$system.default"
echo "$output"
exit 1
fi
done
echo ""
echo "=== All evaluations passed ==="
+7 -2
View File
@@ -17,10 +17,15 @@ on:
- "patches/**"
- ".github/workflows/nix-hashes.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Native runners required: bun install cross-compilation flags (--os/--cpu)
# do not produce byte-identical node_modules as native installs.
compute-hash:
if: github.repository == 'Kilo-Org/kilocode'
strategy:
fail-fast: false
matrix:
@@ -56,7 +61,7 @@ jobs:
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}"
@@ -76,7 +81,7 @@ jobs:
update-hashes:
needs: compute-hash
if: github.event_name != 'pull_request'
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
+18 -1
View File
@@ -7,6 +7,7 @@ on:
jobs:
# kilocode_change start
check-author:
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
uses: ./.github/workflows/check-org-member.yml
with:
username: ${{ github.event.pull_request.user.login }}
@@ -16,7 +17,7 @@ jobs:
check-duplicates:
needs: check-author
if: needs.check-author.outputs['is-member'] == 'false'
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
# kilocode_change end
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
@@ -28,10 +29,23 @@ 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
# kilocode_change start
@@ -40,6 +54,7 @@ jobs:
# kilocode_change end
- name: Build prompt
if: steps.team-check.outputs.is_team != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -56,6 +71,7 @@ jobs:
} > pr_info.txt
- name: Check for duplicate PRs
if: steps.team-check.outputs.is_team != 'true'
env:
# kilocode_change start
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
@@ -73,6 +89,7 @@ jobs:
fi
add-contributor-label:
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change
permissions:
pull-requests: write
+206 -6
View File
@@ -7,6 +7,7 @@ on:
jobs:
# kilocode_change start
check-author:
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
uses: ./.github/workflows/check-org-member.yml
with:
username: ${{ github.event.pull_request.user.login }}
@@ -16,7 +17,7 @@ jobs:
check-standards:
needs: check-author
if: needs.check-author.outputs['is-member'] == 'false'
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
# kilocode_change end
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
@@ -58,10 +59,10 @@ jobs:
repo: context.repo.repo,
issue_number: pr.number
});
const existing = comments.find(c => c.body.includes(markerText));
if (existing) return;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -95,11 +96,11 @@ jobs:
await removeLabel('needs:title');
// Step 2: Check for linked issue (skip for docs/refactor PRs)
const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
// Step 2: Check for linked issue (skip for docs/refactor/feat PRs)
const skipIssueCheck = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
if (skipIssueCheck) {
await removeLabel('needs:issue');
console.log('Skipping issue check for docs/refactor PR');
console.log('Skipping issue check for docs/refactor/feat PR');
return;
}
const query = `
@@ -138,3 +139,202 @@ jobs:
await removeLabel('needs:issue');
console.log('PR meets all standards');
check-compliance:
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check PR template compliance
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const login = pr.user.login;
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
const cutoff = new Date('2026-02-19T00:00:00Z');
const prCreated = new Date(pr.created_at);
if (prCreated < cutoff) {
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
return;
}
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
const body = pr.body || '';
const title = pr.title;
const isDocsRefactorOrFeat = /^(docs|refactor|feat)\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/feat)
if (!isDocsRefactorOrFeat && 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`);
}
+101 -4
View File
@@ -1,5 +1,5 @@
name: publish
run-name: "${{ format('release {0}', inputs.bump) }}"
run-name: "${{ format('{0} {1}', inputs.pre_release && 'pre-release' || 'release', inputs.bump) }}"
on:
# push:
@@ -22,6 +22,11 @@ on:
description: "Override version (optional)"
required: false
type: string
pre_release:
description: "Publish as pre-release (VS Code marketplace + npm rc channel)"
required: false
type: boolean
default: false
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
@@ -33,7 +38,7 @@ permissions:
jobs:
version:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'Kilo-Org/kilo'
if: github.repository == 'Kilo-Org/kilocode'
steps:
- uses: actions/checkout@v3
with:
@@ -41,17 +46,24 @@ jobs:
- uses: ./.github/actions/setup-bun
# kilocode_change start - install deps for changeset changelog generation
- name: Install dependencies
run: bun install
- name: Install Kilo
if: inputs.bump || inputs.version
run: bun i -g @kilocode/cli
# kilocode_change end
- id: version
run: |
./script/version.ts
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
KILO_BUMP: ${{ inputs.bump }}
KILO_VERSION: ${{ inputs.version }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
outputs:
@@ -62,7 +74,7 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'Kilo-Org/kilo'
if: github.repository == 'Kilo-Org/kilocode'
steps:
- uses: actions/checkout@v3
with:
@@ -77,7 +89,9 @@ jobs:
env:
KILO_VERSION: ${{ needs.version.outputs.version }}
KILO_RELEASE: ${{ needs.version.outputs.release }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
- uses: actions/upload-artifact@v4
with:
@@ -90,7 +104,7 @@ jobs:
build-vscode:
needs: build-cli
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'Kilo-Org/kilo'
if: github.repository == 'Kilo-Org/kilocode'
steps:
- uses: actions/checkout@v3
@@ -115,6 +129,8 @@ jobs:
env:
CLI_DIST_DIR: ../../packages/opencode/dist
KILO_VERSION: ${{ needs.build-cli.outputs.version }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
GH_REPO: ${{ github.repository }}
- uses: actions/upload-artifact@v4
with:
@@ -237,11 +253,90 @@ jobs:
# APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
# APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
# kilocode_change start
# Smoke test disabled as a release gate due to infrastructure issues.
# The job is skipped via `if: false` so it no longer blocks publishing.
# Re-enable by restoring the original `if:` condition and uncommenting
# `- smoke-test` in the publish job's `needs` list below.
smoke-test:
name: Smoke Test (pre-publish gate) [DISABLED]
needs:
- version
- build-cli
if: false # was: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-24.04
steps:
- name: Trigger kilo-bench smoke test
id: trigger
env:
GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }}
run: |
BEFORE=$(date -u -d '60 seconds ago' +%Y-%m-%dT%H:%M:%SZ)
gh api repos/Kilo-Org/kilo-bench/dispatches \
--method POST \
-f event_type=smoke-test \
-f 'client_payload[release_tag]=${{ needs.version.outputs.tag }}' \
-f 'client_payload[source_run_id]=${{ github.run_id }}'
# Poll for the run created after our dispatch timestamp.
# The dispatch API returns no run ID, so we query by created time
# and pick the oldest match to avoid grabbing an unrelated run.
echo "Waiting for smoke-test run to appear (dispatched after $BEFORE)..."
for attempt in $(seq 1 30); do
RUN_ID=$(gh api \
"repos/Kilo-Org/kilo-bench/actions/workflows/smoke-test.yml/runs?event=repository_dispatch&created=>=$BEFORE" \
--jq '.workflow_runs | sort_by(.created_at) | .[0].id // empty')
if [[ -n "$RUN_ID" && "$RUN_ID" != "null" ]]; then
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
echo "::notice::Smoke test run: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID"
exit 0
fi
echo " attempt $attempt: run not yet registered, retrying in 10s..."
sleep 10
done
echo "::error::Smoke test run did not appear within 5 minutes after dispatch."
exit 1
- name: Wait for smoke test to complete
env:
GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }}
run: |
RUN_ID="${{ steps.trigger.outputs.run_id }}"
echo "Waiting for run $RUN_ID..."
for i in $(seq 1 60); do
CONCLUSION=$(gh run view "$RUN_ID" \
--repo Kilo-Org/kilo-bench \
--json conclusion \
--jq '.conclusion')
echo " attempt $i: $CONCLUSION"
if [[ "$CONCLUSION" == "success" ]]; then
echo "::notice::Smoke test passed."
exit 0
elif [[ "$CONCLUSION" != "null" && "$CONCLUSION" != "" ]]; then
echo "::error::Smoke test failed with conclusion: $CONCLUSION"
echo "See: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID"
exit 1
fi
sleep 30
done
echo "::error::Smoke test did not complete within 30 minutes."
exit 1
# kilocode_change end
publish:
needs:
- version
- build-cli
- build-vscode
# - smoke-test # disabled: infrastructure issues (see smoke-test job comment)
# - build-tauri
runs-on: ubuntu-24.04
steps:
@@ -312,6 +407,8 @@ jobs:
env:
KILO_VERSION: ${{ needs.version.outputs.version }}
KILO_RELEASE: ${{ needs.version.outputs.release }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
GH_REPO: ${{ github.repository }}
AUR_KEY: ${{ secrets.AUR_KEY }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+24
View File
@@ -0,0 +1,24 @@
name: Check Source Links
on:
pull_request:
paths:
- "packages/kilo-vscode/src/**"
- "packages/kilo-vscode/webview-ui/**"
- "packages/opencode/src/**"
- "packages/kilo-docs/source-links.md"
- "script/extract-source-links.ts"
- ".github/workflows/source-check-links.yml"
workflow_dispatch: # Allow manual trigger
jobs:
source-links-freshness:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Check source-links.md is up to date
run: bun run script/extract-source-links.ts --check
-33
View File
@@ -1,33 +0,0 @@
name: stale-issues
on:
schedule:
- cron: "30 1 * * *" # Daily at 1:30 AM
workflow_dispatch:
env:
DAYS_BEFORE_STALE: 90
DAYS_BEFORE_CLOSE: 7
jobs:
stale:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
issues: write
steps:
- uses: actions/stale@v10
with:
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
stale-issue-label: "stale"
close-issue-message: |
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
Feel free to reopen if you still need this!
stale-issue-message: |
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
remove-stale-when-updated: true
exempt-issue-labels: "pinned,security,feature-request,on-hold"
start-date: "2025-12-27"
+39
View File
@@ -0,0 +1,39 @@
name: storybook
on:
push:
branches: [dev]
paths:
- ".github/workflows/storybook.yml"
- "package.json"
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
pull_request:
branches: [dev]
paths:
- ".github/workflows/storybook.yml"
- "package.json"
- "bun.lock"
- "packages/storybook/**"
- "packages/ui/**"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
if: false
name: storybook build
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Build Storybook
run: bun --cwd packages/storybook build
+23
View File
@@ -3,12 +3,19 @@ name: test-vscode
on:
push:
branches:
- main
- dev
paths:
- "packages/kilo-vscode/**"
- "packages/ui/**"
- "packages/kilo-ui/**"
- ".github/workflows/test-vscode.yml"
pull_request:
paths:
- "packages/kilo-vscode/**"
- "packages/ui/**"
- "packages/kilo-ui/**"
- ".github/workflows/test-vscode.yml"
workflow_dispatch:
jobs:
@@ -30,3 +37,19 @@ jobs:
- name: Run unit tests
working-directory: packages/kilo-vscode
run: bun run test:unit
- name: Check lint (ESLint)
working-directory: packages/kilo-vscode
run: bun run lint
- name: Check formatting (prettier)
working-directory: packages/kilo-vscode
run: bun run format:check
- name: Check for dead code (knip)
working-directory: packages/kilo-vscode
run: bun run knip
- name: Check for kilocode_change markers
working-directory: packages/kilo-vscode
run: bun run check-kilocode-change
+83 -15
View File
@@ -6,10 +6,29 @@ on:
- main
pull_request:
workflow_dispatch:
concurrency:
# Keep every run on main so cancelled checks do not pollute the default branch
# commit history. PRs and other branches still share a group and cancel stale runs.
group: ${{ case(github.ref == 'refs/heads/main', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
cancel-in-progress: true
permissions:
contents: read
checks: write
jobs:
unit:
name: unit (linux)
runs-on: blacksmith-4vcpu-ubuntu-2404
name: unit (${{ matrix.settings.name }})
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
- name: windows
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
defaults:
run:
shell: bash
@@ -24,28 +43,58 @@ jobs:
- name: Configure git identity
run: |
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com"
git config --global user.name "kilo-maintainer[bot]"
- name: Cache Turbo
uses: actions/cache@v4
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests
run: bun turbo test
run: bun turbo test:ci
env:
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e:
# kilocode_change - disabled: packages/app is not actively maintained
if: false
name: e2e (${{ matrix.settings.name }})
needs: unit
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows
host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: 0
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
@@ -58,9 +107,28 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install Playwright browsers
- name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app
run: ${{ matrix.settings.playwright }}
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local
@@ -71,17 +139,20 @@ jobs:
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
KILO_DISABLE_SHARE: "true"
KILO_DISABLE_SESSION_INGEST: "true"
KILO_E2E_REQUIRE_PAID: "true"
# kilocode_change end
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30
- name: Upload Playwright artifacts
if: failure()
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
if-no-files-found: ignore
retention-days: 7
path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results
packages/app/e2e/playwright-report
@@ -90,12 +161,9 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
needs:
- unit
- e2e
if: always()
steps:
- name: Verify upstream test jobs passed
run: |
echo "unit=${{ needs.unit.result }}"
echo "e2e=${{ needs.e2e.result }}"
test "${{ needs.unit.result }}" = "success"
test "${{ needs.e2e.result }}" = "success"
+1
View File
@@ -6,6 +6,7 @@ on:
jobs:
triage:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
+400
View File
@@ -0,0 +1,400 @@
name: Visual Regression Tests
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
check-paths:
name: Check changed paths
runs-on: ubuntu-latest
outputs:
matched: ${{ steps.filter.outputs.matched }}
is_fork: ${{ steps.fork-check.outputs.is_fork }}
steps:
- uses: actions/checkout@v4
- uses: Kilo-Org/paths-filter@master
id: filter
with:
filters: |
matched:
- "packages/kilo-ui/**"
- "packages/ui/**"
- "packages/util/**"
- "packages/sdk/js/**"
- "packages/kilo-vscode/webview-ui/**"
- "packages/kilo-vscode/.storybook/**"
- "packages/kilo-vscode/tests/visual-regression*"
- "packages/kilo-vscode/tests/permission-dock-dropdown*"
- "packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/**"
- ".github/workflows/visual-regression.yml"
- name: Check if PR is from a fork
id: fork-check
run: |
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
echo "is_fork=true" >> "$GITHUB_OUTPUT"
else
echo "is_fork=false" >> "$GITHUB_OUTPUT"
fi
visual-regression:
needs: check-paths
if: needs.check-paths.outputs.matched == 'true'
name: Visual Regression (kilo-ui)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout (internal)
if: needs.check-paths.outputs.is_fork != 'true'
uses: actions/checkout@v4
with:
lfs: true
token: ${{ secrets.BOT_PAT }}
ref: ${{ github.head_ref }}
- name: Checkout (fork)
if: needs.check-paths.outputs.is_fork == 'true'
uses: actions/checkout@v4
with:
lfs: true
- name: Check if HEAD is a baseline update commit
if: needs.check-paths.outputs.is_fork != 'true'
id: check-baseline-commit
run: |
COMMIT_MSG=$(git log -1 --format=%s)
if [ "$COMMIT_MSG" = "chore: update visual regression baselines" ] || [ "$COMMIT_MSG" = "chore: update kilo-vscode visual regression baselines" ]; then
echo "is_baseline_update=true" >> "$GITHUB_OUTPUT"
echo "HEAD commit is a baseline update commit — will not auto-commit again."
else
echo "is_baseline_update=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache Bun modules
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: bun-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('packages/kilo-ui/package.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: bunx playwright install chromium
working-directory: packages/kilo-ui
- name: Install Playwright system deps
run: bunx playwright install-deps chromium
working-directory: packages/kilo-ui
- name: Cache Storybook build
id: storybook-cache
uses: actions/cache@v4
with:
path: packages/kilo-ui/storybook-static
key: storybook-${{ hashFiles('packages/kilo-ui/src/**', 'packages/kilo-ui/.storybook/**', 'packages/ui/src/**', 'packages/kilo-ui/package.json') }}
- name: Build Storybook
if: steps.storybook-cache.outputs.cache-hit != 'true'
run: bun run build-storybook
working-directory: packages/kilo-ui
- name: Generate baselines for new/missing stories
run: bun run test:visual:update
working-directory: packages/kilo-ui
env:
CI: true
PLAYWRIGHT_WORKERS: "4"
- name: Remove stale baselines for deleted stories
run: |
node -e "
const fs = require('fs'), path = require('path');
const idx = JSON.parse(fs.readFileSync('storybook-static/index.json', 'utf8'));
const stories = Object.values(idx.entries || idx.stories || {}).filter(s => s.id && !s.id.endsWith('--docs'));
const expected = new Set(stories.map(s => { const [c,v] = s.id.split('--'); return c + '/' + v + '-chromium-linux.png'; }));
const dir = 'tests/visual-regression.spec.ts-snapshots';
if (!fs.existsSync(dir)) process.exit(0);
for (const component of fs.readdirSync(dir)) {
const cdir = path.join(dir, component);
if (!fs.statSync(cdir).isDirectory()) continue;
for (const file of fs.readdirSync(cdir)) {
const rel = component + '/' + file;
if (file.endsWith('.png') && !expected.has(rel)) { console.log('Removing stale baseline:', rel); fs.unlinkSync(path.join(cdir, file)); }
}
}
"
working-directory: packages/kilo-ui
- name: Check for baseline changes (fork PRs)
if: needs.check-paths.outputs.is_fork == 'true'
run: |
git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/
if git diff --cached --quiet; then
echo "No visual regression detected."
else
echo "::error::Visual regression detected. Screenshot baselines have changed."
echo "::error::Since this PR is from a fork, updated screenshots cannot be committed automatically."
echo "::error::Please ask a Kilo developer for help updating the screenshots."
git diff --cached --stat
exit 1
fi
- name: Fail if baselines still changing after auto-update
if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit.outputs.is_baseline_update == 'true'
run: |
git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/
if git diff --cached --quiet; then
echo "Baselines are stable after auto-update."
else
echo "::error::Visual regression baselines changed again after a previous auto-update commit."
echo "::error::This indicates non-deterministic screenshots that would cause an infinite update loop."
echo "::error::Please investigate the flaky screenshots and add non-deterministic stories to the SKIP set."
git diff --cached --stat
exit 1
fi
- name: Commit and push new baselines (if any)
if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit.outputs.is_baseline_update != 'true'
id: commit-baselines
env:
GH_TOKEN: ${{ secrets.BOT_PAT }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/
if git diff --cached --quiet; then
echo "No new baselines — nothing to commit."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
git commit -m "chore: update visual regression baselines"
git lfs push --all origin
git push --no-verify
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Fail if baselines changed
if: needs.check-paths.outputs.is_fork != 'true' && steps.commit-baselines.outputs.changed == 'true'
run: |
echo "::error::Visual regression baselines changed. New baselines have been committed to the branch. Please pull and review."
exit 1
- name: Upload test results on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-regression-results
path: packages/kilo-ui/test-results/
retention-days: 7
visual-regression-vscode:
needs: check-paths
if: needs.check-paths.outputs.matched == 'true'
name: Visual Regression (kilo-vscode webview)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout (internal)
if: needs.check-paths.outputs.is_fork != 'true'
uses: actions/checkout@v4
with:
lfs: true
token: ${{ secrets.BOT_PAT }}
ref: ${{ github.head_ref }}
- name: Checkout (fork)
if: needs.check-paths.outputs.is_fork == 'true'
uses: actions/checkout@v4
with:
lfs: true
- name: Check if HEAD is a baseline update commit
if: needs.check-paths.outputs.is_fork != 'true'
id: check-baseline-commit-vscode
run: |
COMMIT_MSG=$(git log -1 --format=%s)
if [ "$COMMIT_MSG" = "chore: update visual regression baselines" ] || [ "$COMMIT_MSG" = "chore: update kilo-vscode visual regression baselines" ]; then
echo "is_baseline_update=true" >> "$GITHUB_OUTPUT"
echo "HEAD commit is a baseline update commit — will not auto-commit again."
else
echo "is_baseline_update=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache Bun modules
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: bun-${{ hashFiles('bun.lock') }}
- name: Install dependencies
run: bun install
- name: Cache Playwright browsers
id: playwright-cache-vscode
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-vscode-${{ hashFiles('packages/kilo-vscode/package.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache-vscode.outputs.cache-hit != 'true'
run: bunx playwright install chromium
working-directory: packages/kilo-vscode
- name: Install Playwright system deps
run: bunx playwright install-deps chromium
working-directory: packages/kilo-vscode
- name: Cache Storybook build
id: storybook-cache-vscode
uses: actions/cache@v4
with:
path: packages/kilo-vscode/storybook-static
key: storybook-vscode-${{ hashFiles('packages/kilo-vscode/webview-ui/src/**', 'packages/kilo-vscode/.storybook/**', 'packages/kilo-ui/src/**', 'packages/ui/src/**', 'packages/kilo-vscode/package.json') }}
- name: Build Storybook
if: steps.storybook-cache-vscode.outputs.cache-hit != 'true'
run: bun run build-storybook
working-directory: packages/kilo-vscode
- name: Generate baselines for new/missing stories
run: bun run test:visual:update
working-directory: packages/kilo-vscode
env:
CI: true
PLAYWRIGHT_WORKERS: "4"
- name: Remove stale baselines for deleted stories
run: |
node -e "
const fs = require('fs'), path = require('path');
const idx = JSON.parse(fs.readFileSync('storybook-static/index.json', 'utf8'));
const stories = Object.values(idx.entries || idx.stories || {}).filter(s => s.id && !s.id.endsWith('--docs'));
const expected = new Set(stories.map(s => { const [c,v] = s.id.split('--'); return c + '/' + v + '-chromium-linux.png'; }));
const dir = 'tests/visual-regression.spec.ts-snapshots';
if (!fs.existsSync(dir)) process.exit(0);
for (const component of fs.readdirSync(dir)) {
const cdir = path.join(dir, component);
if (!fs.statSync(cdir).isDirectory()) continue;
for (const file of fs.readdirSync(cdir)) {
const rel = component + '/' + file;
if (file.endsWith('.png') && !expected.has(rel)) { console.log('Removing stale baseline:', rel); fs.unlinkSync(path.join(cdir, file)); }
}
}
"
working-directory: packages/kilo-vscode
- name: Remove stale baselines for permission-dock-dropdown tests
run: |
node -e "
const fs = require('fs'), path = require('path');
const specPath = 'tests/permission-dock-dropdown.spec.ts';
const expected = new Set();
if (fs.existsSync(specPath)) {
const spec = fs.readFileSync(specPath, 'utf8');
const re = /toHaveScreenshot\(\[([^\]]+)\]\)/g;
let m;
while ((m = re.exec(spec)) !== null) {
const parts = m[1].split(',').map(s => s.trim().replace(/[\"']/g, ''));
expected.add(parts.join('/').replace(/\.png$/, '-chromium-linux.png'));
}
}
const dir = 'tests/permission-dock-dropdown.spec.ts-snapshots';
if (!fs.existsSync(dir)) process.exit(0);
for (const sub of fs.readdirSync(dir)) {
const sdir = path.join(dir, sub);
if (!fs.statSync(sdir).isDirectory()) continue;
for (const file of fs.readdirSync(sdir)) {
const rel = sub + '/' + file;
if (file.endsWith('.png') && !expected.has(rel)) {
console.log('Removing stale baseline:', rel);
fs.unlinkSync(path.join(sdir, file));
}
}
}
"
working-directory: packages/kilo-vscode
- name: Check for baseline changes (fork PRs)
if: needs.check-paths.outputs.is_fork == 'true'
run: |
git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/
[ -d packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots ] && git add packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/
if git diff --cached --quiet; then
echo "No visual regression detected."
else
echo "::error::Visual regression detected. Screenshot baselines have changed."
echo "::error::Since this PR is from a fork, updated screenshots cannot be committed automatically."
echo "::error::Please ask a Kilo developer for help updating the screenshots."
git diff --cached --stat
exit 1
fi
- name: Fail if baselines still changing after auto-update
if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit-vscode.outputs.is_baseline_update == 'true'
run: |
git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/
[ -d packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots ] && git add packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/
if git diff --cached --quiet; then
echo "Baselines are stable after auto-update."
else
echo "::error::Visual regression baselines changed again after a previous auto-update commit."
echo "::error::This indicates non-deterministic screenshots that would cause an infinite update loop."
echo "::error::Please investigate the flaky screenshots and add non-deterministic stories to the SKIP set."
git diff --cached --stat
exit 1
fi
- name: Commit and push new baselines (if any)
if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit-vscode.outputs.is_baseline_update != 'true'
id: commit-baselines-vscode
env:
GH_TOKEN: ${{ secrets.BOT_PAT }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/
[ -d packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots ] && git add packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/
if git diff --cached --quiet; then
echo "No new baselines — nothing to commit."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
git commit -m "chore: update kilo-vscode visual regression baselines"
git lfs push --all origin
git push --no-verify
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Fail if baselines changed
if: needs.check-paths.outputs.is_fork != 'true' && steps.commit-baselines-vscode.outputs.changed == 'true'
run: |
echo "::error::Visual regression baselines changed. New baselines have been committed to the branch. Please pull and review."
exit 1
- name: Upload test results on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-regression-vscode-results
path: packages/kilo-vscode/test-results/
retention-days: 7
+16 -3
View File
@@ -1,9 +1,13 @@
.husky/_
.DS_Store
node_modules
.worktrees
.sst
.env
.idea
.idea/*
!.idea/gradle.xml
!.idea/.run
.idea/*.iml
.vscode/*
!.vscode/extensions.json
!.vscode/launch.json
@@ -24,7 +28,7 @@ storybook-static
/result
refs
Session.vim
opencode.json
/opencode.json
a.out
target
.scripts
@@ -32,8 +36,17 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
logs/
*.bun-build
# Telemetry ID
telemetry-id
telemetry-id
tsconfig.tsbuildinfo
# Kilo
.kilo/plans/*upstream-merge-report-*.md
# Test Artifacts
packages/app/.artifacts/
packages/opencode/.artifacts/
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<compositeConfiguration>
<compositeBuild compositeDefinitionSource="SCRIPT">
<builds>
<build path="$PROJECT_DIR$/packages/kilo-jetbrains/build-tasks" name="build-tasks">
<projects>
<project path="$PROJECT_DIR$/packages/kilo-jetbrains/build-tasks" />
</projects>
</build>
</builds>
</compositeBuild>
</compositeConfiguration>
<option name="externalProjectPath" value="$PROJECT_DIR$/packages/kilo-jetbrains" />
<option name="gradleJvm" value="#JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/packages/kilo-jetbrains" />
<option value="$PROJECT_DIR$/packages/kilo-jetbrains/backend" />
<option value="$PROJECT_DIR$/packages/kilo-jetbrains/build-tasks" />
<option value="$PROJECT_DIR$/packages/kilo-jetbrains/frontend" />
<option value="$PROJECT_DIR$/packages/kilo-jetbrains/shared" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+31
View File
@@ -0,0 +1,31 @@
{
"name": ".kilo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@kilocode/plugin": "*"
}
},
"node_modules/@kilocode/plugin": {
"version": "7.2.0",
"license": "MIT",
"dependencies": {
"@kilocode/sdk": "7.2.0",
"zod": "4.1.8"
}
},
"node_modules/@kilocode/sdk": {
"version": "7.2.0",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": ".kilocode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@kilocode/plugin": "*"
}
},
"node_modules/@kilocode/plugin": {
"version": "7.2.0",
"license": "MIT",
"dependencies": {
"@kilocode/sdk": "7.2.0",
"zod": "4.1.8"
}
},
"node_modules/@kilocode/sdk": {
"version": "7.2.0",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
@@ -0,0 +1,327 @@
---
name: vscode-visual-regression
description: Write Storybook stories and visual regression tests for the Kilo VS Code extension webview UI
---
Use this skill when the user asks you to add visual regression tests, screenshot tests, or Storybook stories for components in `packages/kilo-vscode/`.
# Architecture
The VS Code extension uses **Storybook + Playwright** for visual regression testing:
1. **Storybook stories** define UI scenarios using SolidJS components with mock contexts
2. **Playwright** auto-discovers all stories, renders each in headless Chromium, and compares screenshots against baseline PNGs using `toHaveScreenshot()`
3. **Baselines** are Linux-only Chromium PNGs stored in `tests/visual-regression.spec.ts-snapshots/` (tracked via Git LFS)
The test runner at `tests/visual-regression.spec.ts` is fully automatic — it fetches ALL stories from the Storybook index and creates one Playwright test per story. You do NOT write Playwright test code. You only write stories.
# How to add a visual regression test
## Step 1: Decide which story file to use
Stories live in `packages/kilo-vscode/webview-ui/src/stories/`. Existing files and their scope:
| File | Components covered |
| --------------------------- | -------------------------------------------------------- |
| `agent-manager.stories.tsx` | FileTree, DiffPanel, FullScreenDiffView, WorktreeItem |
| `chat.stories.tsx` | ChatView, QuestionDock |
| `composite.stories.tsx` | AssistantMessage with tool cards, permissions, questions |
| `prompt-input.stories.tsx` | PromptInput (sidebar prompt bar) |
| `settings.stories.tsx` | Settings panel, ProvidersTab |
| `history.stories.tsx` | SessionList |
| `shared.stories.tsx` | ModelSelector and shared controls |
Add to an existing file if the component fits. Create a new file only for a genuinely new component area.
## Step 2: Write the story
Every story file follows this exact structure:
```tsx
/** @jsxImportSource solid-js */
/**
* Stories for [ComponentName].
*/
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { StoryProviders } from "./StoryProviders"
// Import the component(s) under test
import { MyComponent } from "../components/path/MyComponent"
const meta: Meta = {
title: "MyCategory", // Becomes the snapshot subdirectory name (lowercased)
parameters: { layout: "padded" }, // or "fullscreen"
}
export default meta
type Story = StoryObj
export const MyStoryName: Story = {
name: "MyComponent — description of variant",
render: () => (
<StoryProviders>
<div style={{ "max-height": "400px", overflow: "auto" }}>
<MyComponent someProp="value" />
</div>
</StoryProviders>
),
}
```
### Key rules
- **Always start with `/** @jsxImportSource solid-js \*/`\*\* — required for SolidJS JSX compilation.
- **Always wrap in `<StoryProviders>`** — provides all required contexts (VSCode, Server, Config, Provider, Session, I18n, Dialog, Marked, Data, Diff, Code). Without it, components that call `useVSCode()`, `useSession()`, etc. will throw.
- **Do NOT set an explicit `width` on the wrapper div.** The Playwright viewport is already 420px wide (or 200px for narrow stories). Setting `width: "420px"` leaves no room for a vertical scrollbar and causes right-side cropping in screenshots. Let the viewport control the width.
- **Use `max-height` not `height` for the wrapper div** when you need to constrain vertical size. A fixed `height` forces a scrollbar even when content is short; `max-height` avoids unnecessary scrollbars that would eat into the available horizontal space.
- **Meta `title`** determines the snapshot subdirectory. Use PascalCase or slash-notation (e.g., `"Composite/Webview"`). Playwright transforms it: `"Composite/Webview"` becomes `composite-webview/` in the snapshots folder.
- **Export name** determines the story ID. The Storybook ID is `{lowercase-title}--{kebab-export-name}`. For example, `title: "Chat"` + `export const ChatViewIdle` produces ID `chat--chat-view-idle`.
- **Snapshot path** is derived automatically: `tests/visual-regression.spec.ts-snapshots/{title-slug}/{variant-slug}.png`. Example: `chat/chat-view-idle-chromium-linux.png`.
### StoryProviders props
```tsx
interface StoryProvidersProps {
data?: any // Override mock data (messages, parts, permissions, etc.)
permissions?: PermissionRequest[] // Active permission requests
questions?: QuestionRequest[] // Active question requests
status?: string // Session status: "idle" | "busy"
sessionID?: string // Custom session ID
noPadding?: boolean // Skip the default 12px padding wrapper
}
```
### Overriding session state
For stories that need custom session behavior (messages, agents, model overrides), use `mockSessionValue()` and override the `SessionContext`:
```tsx
import { mockSessionValue } from "./StoryProviders"
import { SessionContext } from "../context/session"
export const MyCustomStory: Story = {
name: "Component — custom state",
render: () => {
const session = {
...mockSessionValue({ id: "my-session", status: "idle" }),
messages: () => [{ id: "msg-001" }] as any[],
totalCost: () => 0.0023,
}
return (
<StoryProviders sessionID="my-session" status="idle" noPadding>
<SessionContext.Provider value={session as any}>
<MyComponent />
</SessionContext.Provider>
</StoryProviders>
)
},
}
```
### Overriding data context (messages, parts, permissions)
For stories showing assistant messages with tool parts or permissions, build a custom `data` object:
```tsx
import { defaultMockData } from "./StoryProviders"
const SESSION_ID = "story-session-001"
const ASST_MSG_ID = "asst-msg-001"
// Build mock message + parts
const baseMessage = {
id: ASST_MSG_ID,
sessionID: SESSION_ID,
role: "assistant",
// ... see composite.stories.tsx for full shape
}
const myPart = {
id: "part-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
tool: "read",
// ... see composite.stories.tsx for full ToolPart shape
}
function dataWith(parts: any[], permissions?: PermissionRequest[]) {
return {
...defaultMockData,
message: { [SESSION_ID]: [baseMessage] },
part: { [ASST_MSG_ID]: parts },
permission: permissions ? { [SESSION_ID]: permissions } : {},
}
}
export const MyToolStory: Story = {
name: "Tool — with custom parts",
render: () => (
<StoryProviders data={dataWith([myPart])} sessionID={SESSION_ID}>
<AssistantMessage message={baseMessage} />
</StoryProviders>
),
}
```
## Step 3: Handle narrow viewports
For components that should be tested at multiple widths, create separate stories. Stories whose Storybook ID ends in `-200` are automatically rendered at 200px width by the test runner:
```tsx
export const Default420: Story = {
name: "Default — 420px",
render: () => (
<StoryProviders>
<MyComponent />
</StoryProviders>
),
}
export const Default200: Story = {
name: "Default — 200px", // Storybook ID will end in -200
render: () => (
<StoryProviders>
<MyComponent />
</StoryProviders>
),
}
```
The naming convention with `-200` suffix on the **export name** (e.g., `Default200`) produces the ID `mycategory--default-200`, which the test runner detects and uses a 200px viewport for.
## Step 4: Handle animations / non-deterministic content
The test runner injects CSS to disable all animations and transitions. If a story still produces non-deterministic frames (e.g., a spinner captured at a random rotation), add the story ID to the `SKIP` set in `tests/visual-regression.spec.ts`:
```ts
const SKIP = new Set<string>(["agentmanager--worktree-item-busy"])
```
Only skip stories as a last resort. Prefer making the story deterministic (e.g., use a static state instead of an animated one).
## Step 5: Generate baseline images
Baselines are generated on **Linux CI only** (font rendering differs on macOS). The CI workflow at `.github/workflows/visual-regression.yml` auto-runs `bun run test:visual:update` and commits new baselines to the PR branch.
**You do NOT need to generate baseline PNGs locally.** Just write the story, push, and CI handles the rest.
To preview stories locally:
```bash
# From packages/kilo-vscode/
bun run storybook
# Opens at http://localhost:6007
```
# Reference: snapshot directory structure
Snapshots live at `packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/`:
```
tests/visual-regression.spec.ts-snapshots/
{title-slug}/
{variant-slug}-chromium-linux.png
```
The title-slug is derived from the meta `title` (lowercased, slashes become hyphens). The variant-slug is derived from the story export name (kebab-cased). Chromium and Linux suffixes are appended by Playwright.
Example mapping:
| Meta title | Export name | Snapshot path |
| --------------------- | -------------------- | ----------------------------------------------------------- |
| `"Chat"` | `ChatViewIdle` | `chat/chat-view-idle-chromium-linux.png` |
| `"Composite/Webview"` | `GlobWithPermission` | `composite-webview/glob-with-permission-chromium-linux.png` |
| `"Prompt Input"` | `Default200` | `prompt-input/default-200-chromium-linux.png` |
| `"AgentManager"` | `WorktreeItemActive` | `agentmanager/worktree-item-active-chromium-linux.png` |
# Reference: Playwright config
Key settings in `packages/kilo-vscode/playwright.config.ts`:
- Default viewport: **420x720** (VS Code sidebar dimensions)
- Narrow stories (ID ending `-200`): **200x720**
- Max pixel diff ratio: **0.01** (1% tolerance)
- Browser: **Chromium only**
- Storybook: built and served statically on port **6007**
- Animations: forced off via `reducedMotion: "reduce"` + injected CSS
# Reference: CI pipeline
The `visual-regression.yml` workflow triggers on PRs when these paths change:
- `packages/kilo-ui/**`
- `packages/ui/**`
- `packages/util/**`
- `packages/sdk/js/**`
- `packages/kilo-vscode/webview-ui/**`
- `packages/kilo-vscode/.storybook/**`
- `packages/kilo-vscode/tests/visual-regression*`
- `.github/workflows/visual-regression.yml`
CI auto-commits new baselines via Git LFS and fails if screenshots changed, requiring developer review.
# Reference: import paths
Sidebar webview components live in `webview-ui/src/components/` and are imported relative to the stories directory:
```tsx
import { ChatView } from "../components/chat/ChatView"
import { PromptInput } from "../components/chat/PromptInput"
import { AssistantMessage } from "../components/chat/AssistantMessage"
```
Agent Manager components live in `webview-ui/agent-manager/` (one level up from the stories dir):
```tsx
import { FileTree } from "../../agent-manager/FileTree"
import { DiffPanel } from "../../agent-manager/DiffPanel"
import { WorktreeItem } from "../../agent-manager/WorktreeItem"
import "../../agent-manager/agent-manager.css" // Required for AM component styles
```
kilo-ui components are imported via deep subpaths:
```tsx
import { Part } from "@kilocode/kilo-ui/message-part"
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
import { Button } from "@kilocode/kilo-ui/button"
```
SDK types for mock data:
```tsx
import type { AssistantMessage as SDKAssistantMessage, TextPart, ToolPart } from "@kilocode/sdk/v2"
import type { PermissionRequest, QuestionRequest } from "../types/messages"
```
# Reference: Storybook theme globals
The test runner renders every story with dark theme globals:
```
globals=colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern
```
The `.storybook/preview.tsx` applies these via a decorator that calls `applyVscodeTheme()` / `applyKiloTheme()` from kilo-ui. Stories do NOT need to handle theming — it happens automatically.
# Reference: tool override registration
If your story renders `AssistantMessage` with tool parts, you may need to register VS Code tool overrides at the top of the file (outside any story), as done in `composite.stories.tsx`:
```tsx
import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides"
registerVscodeToolOverrides()
```
This ensures tool cards like `bash` render with their VS Code-specific expanded/collapsed behavior.
# Checklist for adding a new visual regression test
1. Identify or create the story file in `webview-ui/src/stories/`
2. Import the component and `StoryProviders` (and optionally `mockSessionValue`, `defaultMockData`)
3. Write the story with explicit dimensions and mock data
4. Wrap in `<StoryProviders>` with appropriate props
5. If testing multiple widths, create separate exports with the `-200` suffix convention
6. If the component has animated states, prefer a static variant or add to the SKIP set
7. Push to PR — CI generates baseline PNGs automatically
8. Review the auto-committed baseline images in the PR diff
+5 -2
View File
@@ -1,3 +1,6 @@
plans/
bun.lock
node_modules
plans
package.json
bun.lock
.gitignore
package-lock.json
-34
View File
@@ -1,34 +0,0 @@
---
description: ALWAYS use this when writing docs
color: "#38A3EE"
---
You are an expert technical documentation writer
You are not verbose
Use a relaxed and friendly tone
The title of the page should be a word or a 2-3 word phrase
The description should be one short line, should not start with "The", should
avoid repeating the title of the page, should be 5-10 words long
Chunks of text should not be more than 2 sentences long
Each section is separated by a divider of 3 dashes
The section titles are short with only the first letter of the word capitalized
The section titles are in the imperative mood
The section titles should not repeat the term used in the page title, for
example, if the page title is "Models", avoid using a section title like "Add
new models". This might be unavoidable in some cases, but try to avoid it.
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
For JS or TS code snippets remove trailing semicolons and any trailing commas
that might not be needed.
If you are making a commit prefix the commit message with `docs:`
+1 -1
View File
@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
model: kilo/anthropic/claude-haiku-4.5
color: "#E67E22"
tools:
"*": false
+19 -2
View File
@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3-pro
model: kilo/google/gemini-3.1-pro-preview
---
You are a professional translator and localization specialist.
@@ -13,10 +13,25 @@ Requirements:
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
- Also preserve every term listed in the Do-Not-Translate glossary below.
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
- Do not modify fenced code blocks.
- Output ONLY the translation (no commentary).
If the target locale is missing, ask the user to provide it.
If no locale-specific glossary exists, use the global glossary only.
---
# Locale-Specific Glossaries
When a locale glossary exists, use it to:
- Apply preferred wording for recurring UI/docs terms in that locale
- Preserve locale-specific do-not-translate terms and casing decisions
- Prefer natural phrasing over literal translation when the locale file calls it out
- If the repo uses a locale alias slug, apply that file too (for example, `pt-BR` maps to `br.md` in this repo)
Locale guidance does not override code/command preservation rules or the global Do-Not-Translate glossary below.
---
@@ -359,6 +374,7 @@ opencode serve --hostname 0.0.0.0 --port 4096
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
opencode session [command]
opencode session list
opencode session delete <sessionID>
opencode stats
opencode uninstall
opencode upgrade
@@ -565,7 +581,7 @@ NODE_ENV
NODE_EXTRA_CA_CERTS
NPM_AUTH_TOKEN
OC_ALLOW_WAYLAND
OPENCODE_API_KEY
KILO_API_KEY
KILO_AUTH_JSON
KILO_AUTO_SHARE
KILO_CLIENT
@@ -598,6 +614,7 @@ KILO_EXPERIMENTAL_MARKDOWN
KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX
KILO_EXPERIMENTAL_OXFMT
KILO_EXPERIMENTAL_PLAN_MODE
KILO_ENABLE_QUESTION_TOOL
KILO_FAKE_VCS
KILO_GIT_BASH_PATH
KILO_MODEL
+59 -29
View File
@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
model: kilo/minimax/minimax-m2.5
color: "#44BA81"
tools:
"*": false
@@ -12,6 +12,8 @@ You are a triage agent responsible for triaging github issues.
Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules.
## Labels
### windows
@@ -30,49 +32,77 @@ Performance-related issues:
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
#### desktop
Desktop app issues:
- `opencode web` command
- The desktop app itself
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
#### nix
**Only** add if the issue explicitly mentions nix.
#### zen
If the issue does not mention nix, do not add nix.
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue mentions nix, assign to `catrielmuller`.
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
#### docs
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Add if the issue requests better documentation or docs updates.
Examples:
#### opentui
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
TUI issues potentially caused by our underlying TUI library:
#### vscode
- Keybindings not working
- Scroll speed issues (too fast/slow/laggy)
- Screen flickering
- Crashes with opentui in the log
Use for issues related to the VS Code extension in `packages/kilo-vscode/`.
**Do not** add for general TUI bugs.
#### gateway
Use for issues related to the Kilo Gateway in `packages/kilo-gateway/`.
When assigning to people here are the following rules:
adamdotdev:
ONLY assign adam if the issue will have the "desktop" label.
Nix:
ONLY assign if the issue will have the "nix" label.
fwang:
ONLY assign fwang if the issue will have the "zen" label.
- catrielmuller
jayair:
ONLY assign jayair if the issue will have the "docs" label.
Models / Providers:
Use for issues about model quality, provider integrations, or broken/new models.
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
- chrarnoldus
Cloud Agents:
Use for issues about cloud agent behavior or infrastructure.
- pandemicsyn
- eshurakov
Core (`packages/opencode/...`):
- kevinvandijk
- marius-kilocode
- catrielmuller
VSCode Extension (`packages/kilo-vscode/...`):
- markijbema
Kilo Gateway (`packages/kilo-gateway/...`):
- jrf0110
Windows:
- catrielmuller (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `catrielmuller`
- If "vscode" label is added, assign to `markijbema`
- If "gateway" label is added, assign to `jrf0110`
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
+46
View File
@@ -0,0 +1,46 @@
---
model: opencode/gpt-5.4
---
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
Do not preserve, merge, or reuse text from the existing file.
The input already contains the exact commit range since the last non-draft release.
The commits are already filtered to the release-relevant packages and grouped into
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
The input may also include a `## Community Contributors Input` section.
Before writing any entry you keep, inspect the real diff with
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
understand the actual code changes and not just the commit message (they may be misleading).
Do not use `git log` or author metadata when deciding attribution.
Rules:
- Write the final file with sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter
- Prefer what changed for users over what code changed internally
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
- If an input bullet has no `(@username)` suffix, do not add one
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
- Do not derive the thank-you section from the main summary bullets
- Do not include the heading `## Community Contributors Input` in the final file
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
<changelog_input>
!`bun script/raw-changelog.ts $ARGUMENTS`
</changelog_input>
+1 -1
View File
@@ -3,7 +3,7 @@ description: "find issue(s) on github"
model: opencode/claude-haiku-4-5
---
Search through existing issues in Kilo-Org/kilo using the gh cli to find issues matching this query:
Search through existing issues in Kilo-Org/kilocode using the gh cli to find issues matching this query:
$ARGUMENTS
+63
View File
@@ -0,0 +1,63 @@
# Locale Glossaries
Use this folder for locale-specific translation guidance that supplements `.opencode/agent/translator.md`.
The global glossary in `translator.md` remains the source of truth for shared do-not-translate terms (commands, code, paths, product names, etc.). These locale files capture community learnings about phrasing and terminology preferences.
## File Naming
- One file per locale
- Use lowercase locale slugs that match docs locales when possible (for example, `zh-cn.md`, `zh-tw.md`)
- If only language-level guidance exists, use the language code (for example, `fr.md`)
- Some repo locale slugs may be aliases/non-BCP47 for consistency (for example, `br` for Brazilian Portuguese / `pt-BR`)
## What To Put In A Locale File
- **Sources**: PRs/issues/discussions that motivated the guidance
- **Do Not Translate (Locale Additions)**: locale-specific terms or casing decisions
- **Preferred Terms**: recurring UI/docs words with preferred translations
- **Guidance**: tone, style, and consistency notes
- **Avoid** (optional): common literal translations or wording we should avoid
- If the repo uses a locale alias slug, document the alias in **Guidance** (for example, prose may mention `pt-BR` while config/examples use `br`)
Prefer guidance that is:
- Repeated across multiple docs/screens
- Easy to apply consistently
- Backed by a community contribution or review discussion
## Template
```md
# <locale> Glossary
## Sources
- PR #12345: https://github.com/anomalyco/opencode/pull/12345
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing)
## Preferred Terms
| English | Preferred | Notes |
| ------- | --------- | --------- |
| prompt | ... | preferred |
| session | ... | preferred |
## Guidance
- Prefer natural phrasing over literal translation
## Avoid
- Avoid ... when ...
```
## Contribution Notes
- Mark entries as preferred when they may evolve
- Keep examples short
- Add or update the `Sources` section whenever you add a new rule
- Prefer PR-backed guidance over invented term mappings; start with general guidance if no term-level corrections exist yet
+28
View File
@@ -0,0 +1,28 @@
# ar Glossary
## Sources
- PR #9947: https://github.com/anomalyco/opencode/pull/9947
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Arabic phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- For RTL text, treat code, commands, and paths as LTR artifacts and keep their character order unchanged
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Arabic terms for the same recurring UI action once a preferred term is established
+34
View File
@@ -0,0 +1,34 @@
# br Glossary
## Sources
- PR #10086: https://github.com/anomalyco/opencode/pull/10086
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Locale code `br` in repo config, code, and paths (repo alias for Brazilian Portuguese)
## Preferred Terms
These are PR-backed locale naming preferences and may evolve.
| English / Context | Preferred | Notes |
| ---------------------------------------- | ------------------------------ | ------------------------------------------------------------- |
| Brazilian Portuguese (prose locale name) | `pt-BR` | Use standard locale naming in prose when helpful |
| Repo locale slug (code/config) | `br` | PR #10086 uses `br` for consistency/simplicity |
| Browser locale detection | `pt`, `pt-br`, `pt-BR` -> `br` | Preserve this mapping in docs/examples about locale detection |
## Guidance
- This file covers Brazilian Portuguese (`pt-BR`), but the repo locale code is `br`
- Use natural Brazilian Portuguese phrasing over literal translation
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- Keep repo locale identifiers as implemented in code/config (`br`) even when prose mentions `pt-BR`
## Avoid
- Avoid changing repo locale code references from `br` to `pt-br` in code snippets, paths, or config examples
- Avoid mixing Portuguese variants when a Brazilian Portuguese form is established
+33
View File
@@ -0,0 +1,33 @@
# bs Glossary
## Sources
- PR #12283: https://github.com/anomalyco/opencode/pull/12283
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed locale naming preferences and may evolve.
| English / Context | Preferred | Notes |
| ---------------------------------- | ---------- | ------------------------------------------------- |
| Bosnian language label (UI) | `Bosanski` | PR #12283 tested switching language to `Bosanski` |
| Repo locale slug (code/config) | `bs` | Preserve in code, config, paths, and examples |
| Browser locale detection (Bosnian) | `bs` | PR #12283 added `bs` locale auto-detection |
## Guidance
- Use natural Bosnian phrasing over literal translation
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- Keep repo locale references as `bs` in code/config, and use `Bosanski` for the user-facing language name when applicable
## Avoid
- Avoid changing repo locale references from `bs` to another slug in code snippets or config examples
- Avoid translating product and protocol names that are fixed identifiers
+27
View File
@@ -0,0 +1,27 @@
# da Glossary
## Sources
- PR #9821: https://github.com/anomalyco/opencode/pull/9821
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Danish phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Danish terms for the same recurring UI action once a preferred term is established
+27
View File
@@ -0,0 +1,27 @@
# de Glossary
## Sources
- PR #9817: https://github.com/anomalyco/opencode/pull/9817
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural German phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple German terms for the same recurring UI action once a preferred term is established
+27
View File
@@ -0,0 +1,27 @@
# es Glossary
## Sources
- PR #9817: https://github.com/anomalyco/opencode/pull/9817
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Spanish phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Spanish terms for the same recurring UI action once a preferred term is established
+27
View File
@@ -0,0 +1,27 @@
# fr Glossary
## Sources
- PR #9821: https://github.com/anomalyco/opencode/pull/9821
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural French phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple French terms for the same recurring UI action once a preferred term is established
+33
View File
@@ -0,0 +1,33 @@
# ja Glossary
## Sources
- PR #9821: https://github.com/anomalyco/opencode/pull/9821
- PR #13160: https://github.com/anomalyco/opencode/pull/13160
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed wording preferences and may evolve.
| English / Context | Preferred | Notes |
| --------------------------- | ----------------------- | ------------------------------------- |
| WSL integration (UI label) | `WSL連携` | PR #13160 prefers this over `WSL統合` |
| WSL integration description | `WindowsのWSL環境で...` | PR #13160 improved phrasing naturally |
## Guidance
- Prefer natural Japanese phrasing over literal translation
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- In WSL integration text, follow PR #13160 wording direction for more natural Japanese phrasing
## Avoid
- Avoid `WSL統合` in the WSL integration UI context where `WSL連携` is the reviewed wording
- Avoid translating product and protocol names that are fixed identifiers
+27
View File
@@ -0,0 +1,27 @@
# ko Glossary
## Sources
- PR #9817: https://github.com/anomalyco/opencode/pull/9817
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Korean phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Korean terms for the same recurring UI action once a preferred term is established
+38
View File
@@ -0,0 +1,38 @@
# no Glossary
## Sources
- PR #10018: https://github.com/anomalyco/opencode/pull/10018
- PR #12935: https://github.com/anomalyco/opencode/pull/12935
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Sound names (PR #10018 notes these were intentionally left untranslated)
## Preferred Terms
These are PR-backed corrections and may evolve.
| English / Context | Preferred | Notes |
| ----------------------------------- | ------------ | ----------------------------- |
| Save (data persistence action) | `Lagre` | Prefer over `Spare` |
| Disabled (feature/state) | `deaktivert` | Prefer over `funksjonshemmet` |
| API keys | `API Nøkler` | Prefer over `API Taster` |
| Cost (noun) | `Kostnad` | Prefer over verb form `Koste` |
| Show/View (imperative button label) | `Vis` | Prefer over `Utsikt` |
## Guidance
- Prefer natural Norwegian Bokmal (Bokmål) wording over literal translation
- Keep tone clear and practical in UI labels
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- Keep recurring UI terms consistent once a preferred term is chosen
## Avoid
- Avoid `Spare` for save actions in persistence contexts
- Avoid `funksjonshemmet` for disabled feature states
- Avoid `API Taster`, `Koste`, and `Utsikt` in the corrected contexts above
+27
View File
@@ -0,0 +1,27 @@
# pl Glossary
## Sources
- PR #9884: https://github.com/anomalyco/opencode/pull/9884
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Polish phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Polish terms for the same recurring UI action once a preferred term is established
+27
View File
@@ -0,0 +1,27 @@
# ru Glossary
## Sources
- PR #9882: https://github.com/anomalyco/opencode/pull/9882
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections.
## Guidance
- Prefer natural Russian phrasing over literal translation
- Keep tone clear and direct in UI labels and docs prose
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
## Avoid
- Avoid translating product and protocol names that are fixed identifiers
- Avoid mixing multiple Russian terms for the same recurring UI action once a preferred term is established
+34
View File
@@ -0,0 +1,34 @@
# th Glossary
## Sources
- PR #10809: https://github.com/anomalyco/opencode/pull/10809
- PR #11496: https://github.com/anomalyco/opencode/pull/11496
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only in commands, package names, paths, or code)
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed preferences and may evolve.
| English / Context | Preferred | Notes |
| ------------------------------------- | --------------------- | -------------------------------------------------------------------------------- |
| Thai language label in language lists | `ไทย` | PR #10809 standardized this across locales |
| Language names in language pickers | Native names (static) | PR #11496: keep names like `English`, `Deutsch`, `ไทย` consistent across locales |
## Guidance
- Prefer natural Thai phrasing over literal translation
- Keep tone short and clear for buttons and labels
- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths
- Keep language names static/native in language pickers instead of translating them per current locale (PR #11496)
## Avoid
- Avoid translating language names differently per current locale in language lists
- Avoid changing `ไทย` to another display form for the Thai language option unless the product standard changes
+38
View File
@@ -0,0 +1,38 @@
# tr Glossary
## Sources
- PR #15835: https://github.com/anomalyco/opencode/pull/15835
## Do Not Translate (Locale Additions)
- `OpenCode` (preserve casing in prose, docs, and UI copy)
- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
- Commands, flags, file paths, and code literals (keep exactly as written)
## Preferred Terms
These are PR-backed wording preferences and may evolve.
| English / Context | Preferred | Notes |
| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` |
| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` |
| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase |
| `<TAB>` key label | `Tab` | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful |
## Guidance
- Prefer natural Turkish phrasing over literal translation
- Merge broken sentence fragments into one clear sentence when the source is a single thought
- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
## Avoid
- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals
+42
View File
@@ -0,0 +1,42 @@
# zh-cn Glossary
## Sources
- PR #13942: https://github.com/anomalyco/opencode/pull/13942
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only when it is part of commands, package names, paths, or code)
- `Kilo Zen`
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- `Model Context Protocol` (prefer the English expansion when introducing `MCP`)
## Preferred Terms
These are preferred terms for docs/UI prose and may evolve.
| English | Preferred | Notes |
| ----------------------- | --------- | ------------------------------------------- |
| prompt | 提示词 | Keep `--prompt` unchanged in flags/code |
| session | 会话 | |
| provider | 提供商 | |
| share link / shared URL | 分享链接 | Prefer `分享` for user-facing share actions |
| headless (server) | 无界面 | Docs wording |
| authentication | 认证 | Prefer in auth/OAuth contexts |
| cache | 缓存 | |
| keybind / shortcut | 快捷键 | User-facing docs wording |
| workflow | 工作流 | e.g. GitHub Actions workflow |
## Guidance
- Prefer natural, concise phrasing over literal translation
- Keep the tone direct and friendly (PR #13942 consistently moved wording in this direction)
- Preserve technical artifacts exactly: commands, flags, code, inline code, URLs, file paths, model IDs
- Keep enum-like values in English when they are literals (for example, `default`, `json`)
- Prefer consistent terminology across pages once a term is chosen (`会话`, `提供商`, `提示词`, etc.)
## Avoid
- Avoid `opencode` in prose when referring to the product name; use `OpenCode`
- Avoid mixing alternative terms for the same concept across docs when a preferred term is already established
+42
View File
@@ -0,0 +1,42 @@
# zh-tw Glossary
## Sources
- PR #13942: https://github.com/anomalyco/opencode/pull/13942
## Do Not Translate (Locale Additions)
- `Kilo` (preserve casing in prose; keep `kilo` only when it is part of commands, package names, paths, or code)
- `Kilo Zen`
- `Kilo CLI`
- `CLI`, `TUI`, `MCP`, `OAuth`
- `Model Context Protocol` (prefer the English expansion when introducing `MCP`)
## Preferred Terms
These are preferred terms for docs/UI prose and may evolve.
| English | Preferred | Notes |
| ----------------------- | --------- | ------------------------------------------- |
| prompt | 提示詞 | Keep `--prompt` unchanged in flags/code |
| session | 工作階段 | |
| provider | 供應商 | |
| share link / shared URL | 分享連結 | Prefer `分享` for user-facing share actions |
| headless (server) | 無介面 | Docs wording |
| authentication | 認證 | Prefer in auth/OAuth contexts |
| cache | 快取 | |
| keybind / shortcut | 快捷鍵 | User-facing docs wording |
| workflow | 工作流程 | e.g. GitHub Actions workflow |
## Guidance
- Prefer natural, concise phrasing over literal translation
- Keep the tone direct and friendly (PR #13942 consistently moved wording in this direction)
- Preserve technical artifacts exactly: commands, flags, code, inline code, URLs, file paths, model IDs
- Keep enum-like values in English when they are literals (for example, `default`, `json`)
- Prefer consistent terminology across pages once a term is chosen (`工作階段`, `供應商`, `提示詞`, etc.)
## Avoid
- Avoid `opencode` in prose when referring to the product name; use `OpenCode`
- Avoid mixing alternative terms for the same concept across docs when a preferred term is already established
+5
View File
@@ -8,6 +8,11 @@
"options": {},
},
},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
},
},
"mcp": {},
"tools": {
"github-triage": false,
+223
View File
@@ -0,0 +1,223 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"nord0": "#2E3440",
"nord1": "#3B4252",
"nord2": "#434C5E",
"nord3": "#4C566A",
"nord4": "#D8DEE9",
"nord5": "#E5E9F0",
"nord6": "#ECEFF4",
"nord7": "#8FBCBB",
"nord8": "#88C0D0",
"nord9": "#81A1C1",
"nord10": "#5E81AC",
"nord11": "#BF616A",
"nord12": "#D08770",
"nord13": "#EBCB8B",
"nord14": "#A3BE8C",
"nord15": "#B48EAD"
},
"theme": {
"primary": {
"dark": "nord10",
"light": "nord9"
},
"secondary": {
"dark": "nord9",
"light": "nord9"
},
"accent": {
"dark": "nord7",
"light": "nord7"
},
"error": {
"dark": "nord11",
"light": "nord11"
},
"warning": {
"dark": "nord12",
"light": "nord12"
},
"success": {
"dark": "nord14",
"light": "nord14"
},
"info": {
"dark": "nord8",
"light": "nord10"
},
"text": {
"dark": "nord6",
"light": "nord0"
},
"textMuted": {
"dark": "#8B95A7",
"light": "nord1"
},
"background": {
"dark": "nord0",
"light": "nord6"
},
"backgroundPanel": {
"dark": "nord1",
"light": "nord5"
},
"backgroundElement": {
"dark": "nord2",
"light": "nord4"
},
"border": {
"dark": "nord2",
"light": "nord3"
},
"borderActive": {
"dark": "nord3",
"light": "nord2"
},
"borderSubtle": {
"dark": "nord2",
"light": "nord3"
},
"diffAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffContext": {
"dark": "#8B95A7",
"light": "nord3"
},
"diffHunkHeader": {
"dark": "#8B95A7",
"light": "nord3"
},
"diffHighlightAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffHighlightRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffAddedBg": {
"dark": "#36413C",
"light": "#E6EBE7"
},
"diffRemovedBg": {
"dark": "#43393D",
"light": "#ECE6E8"
},
"diffContextBg": {
"dark": "nord1",
"light": "nord5"
},
"diffLineNumber": {
"dark": "nord2",
"light": "nord4"
},
"diffAddedLineNumberBg": {
"dark": "#303A35",
"light": "#DDE4DF"
},
"diffRemovedLineNumberBg": {
"dark": "#3C3336",
"light": "#E4DDE0"
},
"markdownText": {
"dark": "nord4",
"light": "nord0"
},
"markdownHeading": {
"dark": "nord8",
"light": "nord10"
},
"markdownLink": {
"dark": "nord9",
"light": "nord9"
},
"markdownLinkText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCode": {
"dark": "nord14",
"light": "nord14"
},
"markdownBlockQuote": {
"dark": "#8B95A7",
"light": "nord3"
},
"markdownEmph": {
"dark": "nord12",
"light": "nord12"
},
"markdownStrong": {
"dark": "nord13",
"light": "nord13"
},
"markdownHorizontalRule": {
"dark": "#8B95A7",
"light": "nord3"
},
"markdownListItem": {
"dark": "nord8",
"light": "nord10"
},
"markdownListEnumeration": {
"dark": "nord7",
"light": "nord7"
},
"markdownImage": {
"dark": "nord9",
"light": "nord9"
},
"markdownImageText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCodeBlock": {
"dark": "nord4",
"light": "nord0"
},
"syntaxComment": {
"dark": "#8B95A7",
"light": "nord3"
},
"syntaxKeyword": {
"dark": "nord9",
"light": "nord9"
},
"syntaxFunction": {
"dark": "nord8",
"light": "nord8"
},
"syntaxVariable": {
"dark": "nord7",
"light": "nord7"
},
"syntaxString": {
"dark": "nord14",
"light": "nord14"
},
"syntaxNumber": {
"dark": "nord15",
"light": "nord15"
},
"syntaxType": {
"dark": "nord7",
"light": "nord7"
},
"syntaxOperator": {
"dark": "nord9",
"light": "nord9"
},
"syntaxPunctuation": {
"dark": "nord4",
"light": "nord0"
}
}
}
+937
View File
@@ -0,0 +1,937 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
}
const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback
if (!value.trim()) return fallback
return value
}
const num = (value: unknown, fallback: number) => {
if (typeof value !== "number") return fallback
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
}
type Route = {
modal: string
screen: string
}
type State = {
tab: number
count: number
source: string
note: string
selected: string
local: number
}
const cfg = (options: Record<string, unknown> | undefined) => {
return {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
}
}
const names = (input: Cfg) => {
return {
modal: `${input.route}.modal`,
screen: `${input.route}.screen`,
}
}
type Keys = TuiKeybindSet
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
text: "#f0f0f0",
muted: "#a5a5a5",
accent: "#5f87ff",
}
type Color = RGBA | string
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
const value = map[name]
if (typeof value === "string") return value
if (value instanceof RGBA) return value
return fallback
}
const look = (map: Record<string, unknown>) => {
return {
panel: ink(map, "backgroundPanel", ui.panel),
border: ink(map, "border", ui.border),
text: ink(map, "text", ui.text),
muted: ink(map, "textMuted", ui.muted),
accent: ink(map, "primary", ui.accent),
selected: ink(map, "selectedListItemText", ui.text),
}
}
const tone = (api: TuiPluginApi) => {
return look(api.theme.current)
}
type Skin = {
panel: Color
border: Color
text: Color
muted: Color
accent: Color
selected: Color
}
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
return (
<box
onMouseUp={() => {
props.run()
}}
backgroundColor={props.on ? props.skin.accent : props.skin.border}
paddingLeft={1}
paddingRight={1}
>
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
</box>
)
}
const parse = (params: Record<string, unknown> | undefined) => {
const tab = typeof params?.tab === "number" ? params.tab : 0
const count = typeof params?.count === "number" ? params.count : 0
const source = typeof params?.source === "string" ? params.source : "unknown"
const note = typeof params?.note === "string" ? params.note : ""
const selected = typeof params?.selected === "string" ? params.selected : ""
const local = typeof params?.local === "number" ? params.local : 0
return {
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
count,
source,
note,
selected,
local: Math.max(0, local),
}
}
const current = (api: TuiPluginApi, route: Route) => {
const value = api.route.current
const ok = Object.values(route).includes(value.name)
if (!ok) return parse(undefined)
if (!("params" in value)) return parse(undefined)
return parse(value.params)
}
const opts = [
{
title: "Overview",
value: 0,
description: "Switch to overview tab",
},
{
title: "Counter",
value: 1,
description: "Switch to counter tab",
},
{
title: "Help",
value: 2,
description: "Switch to help tab",
},
]
const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{input.label} host overlay</b>
</text>
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
<box flexDirection="row" gap={1}>
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
</box>
</box>
))
}
const warn = (api: TuiPluginApi, route: Route, value: State) => {
const DialogAlert = api.ui.DialogAlert
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogAlert
title="Smoke alert"
message="Testing built-in alert dialog"
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
/>
))
}
const check = (api: TuiPluginApi, route: Route, value: State) => {
const DialogConfirm = api.ui.DialogConfirm
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogConfirm
title="Smoke confirm"
message="Apply +1 to counter?"
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
/>
))
}
const entry = (api: TuiPluginApi, route: Route, value: State) => {
const DialogPrompt = api.ui.DialogPrompt
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogPrompt
title="Smoke prompt"
value={value.note}
onConfirm={(note) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
}}
onCancel={() => {
api.ui.dialog.clear()
api.route.navigate(route.screen, value)
}}
/>
))
}
const picker = (api: TuiPluginApi, route: Route, value: State) => {
const DialogSelect = api.ui.DialogSelect
api.ui.dialog.setSize("medium")
api.ui.dialog.replace(() => (
<DialogSelect
title="Smoke select"
options={opts}
current={value.tab}
onSelect={(item) => {
api.ui.dialog.clear()
api.route.navigate(route.screen, {
...value,
tab: typeof item.value === "number" ? item.value : value.tab,
selected: item.title,
source: "select",
})
}}
/>
))
}
const Screen = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
meta: TuiPluginMeta
params?: Record<string, unknown>
}) => {
const dim = useTerminalDimensions()
const value = parse(props.params)
const skin = tone(props.api)
const set = (local: number, base?: State) => {
const next = base ?? current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
}
const push = (base?: State) => {
const next = base ?? current(props.api, props.route)
set(next.local + 1, next)
}
const open = () => {
const next = current(props.api, props.route)
if (next.local > 0) return
set(1, next)
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
if (props.keys.match("home", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
if (props.keys.match("left", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
}
})
return (
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
<box
flexDirection="column"
width="100%"
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
>
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text fg={skin.text}>
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
{tabs.map((item, i) => {
const on = value.tab === i
return (
<Btn
txt={item}
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
skin={skin}
on={on}
/>
)
})}
</box>
<box
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexGrow={1}
>
{value.tab === 0 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Route: {props.route.screen}</text>
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
<text fg={skin.muted}>
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
</text>
<text fg={skin.muted}>plugin source: {props.meta.source}</text>
<text fg={skin.muted}>source: {value.source}</text>
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
<text fg={skin.muted}>local stack depth: {value.local}</text>
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
</box>
) : null}
{value.tab === 1 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
</text>
</box>
) : null}
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
</text>
<text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
</text>
<text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
</box>
) : null}
</box>
<box flexDirection="row" gap={1} paddingTop={1}>
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
<Btn txt="local overlay" run={show} skin={skin} />
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
</box>
</box>
<box
visible={value.local > 0}
width={dim().width}
height={dim().height}
alignItems="center"
position="absolute"
zIndex={3000}
paddingTop={dim().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
onMouseUp={() => {
pop()
}}
>
<box
onMouseUp={(evt) => {
evt.stopPropagation()
}}
width={60}
maxWidth={dim().width - 2}
backgroundColor={skin.panel}
border
borderColor={skin.border}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
flexDirection="column"
>
<text fg={skin.text}>
<b>{props.input.label} local overlay</b>
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
</text>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
<Btn txt="pop" run={pop} skin={skin} />
</box>
</box>
</box>
</box>
)
}
const Modal = (props: {
api: TuiPluginApi
input: Cfg
route: Route
keys: Keys
params?: Record<string, unknown>
}) => {
const Dialog = props.api.ui.Dialog
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
if (props.keys.match("modal_accept", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
}
})
return (
<box width="100%" height="100%" backgroundColor={skin.panel}>
<Dialog onClose={() => props.api.route.navigate("home")}>
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
</text>
<box flexDirection="row" gap={1}>
<Btn
txt="open screen"
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
skin={skin}
on
/>
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
</box>
</box>
</Dialog>
</box>
)
}
const home = (api: TuiPluginApi, input: Cfg) => ({
slots: {
home_logo(ctx) {
const map = ctx.theme.current
const skin = look(map)
const art = [
" $$\\",
" $$ |",
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
]
const fill = [
skin.accent,
skin.muted,
ink(map, "info", ui.accent),
skin.text,
ink(map, "success", ui.accent),
ink(map, "warning", ui.accent),
ink(map, "secondary", ui.accent),
ink(map, "error", ui.accent),
]
return (
<box flexDirection="column">
{art.map((line, i) => (
<text fg={fill[i]}>{line}</text>
))}
</box>
)
},
home_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify prompt-right slot passthrough",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
</text>
</box>
)
return (
<Prompt
workspaceID={value.workspace_id}
hint={hint}
right={
<box flexDirection="row" gap={1}>
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
</box>
}
placeholders={{ normal, shell }}
/>
)
},
home_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = value.workspace_id?.slice(0, 8) ?? "none"
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
</text>
)
},
session_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
</text>
)
},
smoke_prompt_right(ctx, value) {
const skin = look(ctx.theme.current)
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
const label = typeof value.label === "string" ? value.label : input.label
return (
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
</text>
)
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)
const text = "extra content in the unified home bottom slot"
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
width="100%"
>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}>{input.label}</span> {text}
</text>
</box>
</box>
)
},
},
})
const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
order,
slots: {
sidebar_content(ctx, value) {
const skin = look(ctx.theme.current)
return (
<box
border
borderColor={skin.border}
backgroundColor={skin.panel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="column"
gap={1}
>
<text fg={skin.accent}>
<b>{title}</b>
</text>
<text fg={skin.text}>{text}</text>
<text fg={skin.muted}>
{input.label} order {order} · session {value.session_id.slice(0, 8)}
</text>
</box>
)
},
},
})
const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
home(api, input),
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
]
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
},
onSelect: () => {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
},
onSelect: () => {
warn(api, route, current(api, route))
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
},
onSelect: () => {
check(api, route, current(api, route))
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
}
const tui: TuiPlugin = async (api, options, meta) => {
if (options?.enabled === false) return
await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const route = names(value)
const keys = api.keybind.create(bind, value.keybinds)
const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post)
api.lifecycle.onDispose(() => {
api.renderer.removePostProcessFn(post)
})
api.route.register([
{
name: route.screen,
render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
},
{
name: route.modal,
render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
},
])
reg(api, value, keys)
for (const item of slot(api, value)) {
api.slots.register(item)
}
}
const plugin: TuiPluginModule & { id: string } = {
id: "tui-smoke",
tui,
}
export default plugin
-42
View File
@@ -1,42 +0,0 @@
---
name: bun-file-io
description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
---
## Use this when
- Editing file I/O or scans in `packages/opencode`
- Handling directory operations or external tools
## Bun file APIs (from Bun docs)
- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read.
- Metadata: `file.size`, `file.type`, `file.name`.
- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files.
- `Bun.file(...).delete()` deletes a file.
- `file.writer()` returns a FileSink for incremental writes.
- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans.
- Use `Bun.which` to find a binary, then `Bun.spawn` to run it.
- `Bun.readableStreamToText/Bytes/JSON` for stream output.
## When to use node:fs
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations).
## Repo patterns
- Prefer Bun APIs over Node `fs` for file access.
- Check `Bun.file(...).exists()` before reading.
- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`.
- Use `Bun.Glob` + `Array.fromAsync` for scans.
- Decode tool stderr with `Bun.readableStreamToText`.
- For large writes, use `Bun.write(Bun.file(path), text)`.
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
Use Filesystem.exists(...) instead if path can be file or directory
## Quick checklist
- Use Bun APIs first.
- Use `path.join`/`path.resolve` for paths.
- Prefer promise `.catch(...)` over `try/catch` when possible.
+1
View File
@@ -0,0 +1 @@
smoke-theme.json
+10 -2
View File
@@ -1,6 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@kilocode/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
@@ -24,7 +23,16 @@ interface PR {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
-10
View File
@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+20 -3
View File
@@ -1,7 +1,19 @@
/// <reference path="../env.d.ts" />
// import { Octokit } from "@octokit/rest"
import { tool } from "@kilocode/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}
function getIssueNumber(): number {
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
@@ -26,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: {
assignee: tool.schema
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
-88
View File
@@ -1,88 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
You can assign the following users:
- thdxr
- adamdotdevin
- fwang
- jayair
- kommander
- rekram1-node
You can use the following labels:
- nix
- opentui
- perf
- web
- zen
- docs
Always try to assign an issue, if in doubt, assign rekram1-node to it.
## Breakdown of responsibilities:
### thdxr
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
This relates to OpenCode server primarily but has overlap with just about anything
### adamdotdevin
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
### fwang
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
### jayair
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
### kommander
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
- random characters on screen
- keybinds not working on different terminals
- general terminal stuff
Then assign the issue to Him.
### rekram1-node
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
If no one else makes sense to assign, assign rekram1-node to it.
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
## Breakdown of Labels:
### nix
Any issue that mentions nix, or nixos should have a nix label
### opentui
Anything relating to the TUI itself should have an opentui label
### perf
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
### desktop
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
### zen
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
### docs
Anything related to the documentation should have a docs label
### windows
Use for any issue that involves the windows OS
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
}
}
]
]
}
+27
View File
@@ -0,0 +1,27 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run JetBrains Plugin" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/packages/kilo-jetbrains" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runIde" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>false</GradleProfilingDisabled>
<GradleCoverageDisabled>false</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>
+54
View File
@@ -65,6 +65,20 @@
},
"problemMatcher": []
},
{
"label": "VSCode - Install",
"type": "shell",
"command": "bun",
"args": ["install", "--frozen-lockfile"],
"group": "build",
"presentation": {
"reveal": "silent"
},
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "VSCode - Compile",
"type": "shell",
@@ -77,6 +91,7 @@
"options": {
"cwd": "${workspaceFolder}/packages/kilo-vscode"
},
"dependsOn": ["VSCode - Install"],
"problemMatcher": ["$tsc", "$eslint-stylish"]
},
{
@@ -99,6 +114,45 @@
"label": "VSCode - Tests (Composite)",
"dependsOn": ["VSCode - Watch", "VSCode - Tests"],
"problemMatcher": []
},
{
"label": "VSCode - DevSnapshot - Build",
"type": "shell",
"command": "bun run snapshot:build",
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"options": {
"cwd": "${workspaceFolder}/packages/kilo-vscode"
},
"problemMatcher": []
},
{
"label": "VSCode - DevSnapshot - Install",
"type": "shell",
"command": "bun run snapshot:install",
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"options": {
"cwd": "${workspaceFolder}/packages/kilo-vscode",
"env": {
"VSCODE_EXEC_PATH": "${execPath}"
}
},
"problemMatcher": []
}
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "bunx",
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
}
}
}
+48
View File
@@ -10,10 +10,16 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
## Build and Dev
- **Dev**: `bun run dev` (runs from root) or `bun run --cwd packages/opencode --conditions=browser src/index.ts`
- **Dev with params**: `bun dev -- help`
- **Extension**: `bun run extension` (build + launch VS Code with the extension in dev mode). Pass `--no-build` to skip the build.
- **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`)
- **Test**: `bun test` from `packages/opencode/` (NOT from root -- root blocks tests)
- **Single test**: `bun test test/tool/tool.test.ts` from `packages/opencode/`
- **SDK regen**: After changing server endpoints in `packages/opencode/src/server/`, run `./script/generate.ts` from root to regenerate `packages/sdk/js/`
- **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing.
- **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale.
- **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing.
- **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name.
## Products
@@ -63,6 +69,17 @@ Prefer `const`.
Good:
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
const foo = condition ? 1 : 2
```
@@ -150,10 +167,26 @@ const bazFoo = 3
You MUST avoid using `mocks` as much as possible.
Tests MUST test actual implementation, do not duplicate logic into a test.
## Commit Conventions
[Conventional Commits](https://www.conventionalcommits.org/) with scopes matching packages: `vscode`, `cli`, `agent-manager`, `sdk`, `ui`, `i18n`, `kilo-docs`, `gateway`, `telemetry`, `desktop`. Omit scope when spanning multiple packages.
## Changesets
User-facing changes (features, fixes, breaking changes) require a changeset file for release notes. Run `bunx changeset add` or manually create `.changeset/<slug>.md`. Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. See `.changeset/README.md` for details.
Changeset descriptions appear directly in release notes and are read by end users. Keep them concise and feature-oriented — describe **what changed from the user's perspective**, not implementation details. Write in imperative mood (e.g. "Support exporting conversations as markdown" not "Add a new export handler that serializes session messages to .md files").
## Pull Requests
PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus on intent and context a reviewer can't get from the diff — skip file-by-file inventories, test result summaries, and anything obvious from the code itself.
## Fork Merge Process
Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode).
**Very important**: when planning or coding, update shared files with OpenCode as last resort! Everything is shared code from OpenCode, except folders that contain `kilo` in the name or have a parent directory that contains `kilo` in the name. Example of kilo specific folders: `packages/opencode/src/kilocode/` and `packages/kilo-docs/`. Always look for ways to implement your feature or fix in a way that minimizes changes to shared code.
### Minimizing Merge Conflicts
We regularly merge upstream changes from opencode. To minimize merge conflicts and keep the sync process smooth:
@@ -197,6 +230,21 @@ const bar = 2
// kilocode_change - new file
```
<!-- prettier-ignore -->
**JSX/TSX (inside JSX templates):**
<!-- prettier-ignore -->
```tsx
{/* kilocode_change */}
```
<!-- prettier-ignore -->
```tsx
{/* kilocode_change start */}
<MyComponent />
{/* kilocode_change end */}
```
#### When markers are NOT needed
Code in these paths is Kilo Code-specific and does NOT need `kilocode_change` markers:
View File
+38 -6
View File
@@ -16,7 +16,7 @@ The Kilo Community is [on Discord](https://kilo.ai/discord).
## Developing Kilo CLI
- **Requirements:** Bun 1.3+
- **Requirements:** Bun 1.3.10+
- Install dependencies and start the dev server from the repo root:
```bash
@@ -24,9 +24,19 @@ The Kilo Community is [on Discord](https://kilo.ai/discord).
bun dev
```
### Developing the VS Code Extension
Build and launch the extension in an isolated VS Code instance:
```bash
bun run extension # Build + launch in dev mode
```
This auto-detects VS Code on macOS, Linux, and Windows. Override with `--app-path PATH` or `VSCODE_EXEC_PATH`. Use `--insiders` to prefer Insiders, `--workspace PATH` to open a specific folder, or `--clean` to reset cached state.
### Running against a different directory
By default, `bun dev` runs Kilo CLI in the `packages/kilo-cli` directory. To run it against a different directory or repository:
By default, `bun dev` runs Kilo CLI in the `packages/opencode` directory. To run it against a different directory or repository:
```bash
bun dev <directory>
@@ -43,13 +53,13 @@ bun dev .
To compile a standalone executable:
```bash
./packages/kilo-cli/script/build.ts --single
./packages/opencode/script/build.ts --single
```
Then run it with:
```bash
./packages/kilo-cli/dist/kilo-cli-<platform>/bin/kilo
./packages/opencode/dist/@kilocode/cli-<platform>/bin/kilo
```
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
@@ -62,14 +72,32 @@ During development, `bun dev` is the local equivalent of the built `kilo` comman
# Development (from project root)
bun dev --help # Show all available commands
bun dev serve # Start headless API server
bun dev web # Start server + open web interface
# Production
kilo --help # Show all available commands
kilo serve # Start headless API server
kilo web # Start server + open web interface
```
### Testing with a local backend
To point the CLI at a local backend (e.g., a locally running Kilo API server on port 3000), set the `KILO_API_URL` environment variable:
```bash
KILO_API_URL=http://localhost:3000 bun dev
```
This redirects all gateway traffic (auth, model listing, provider routing, profile, etc.) to your local server. The default is `https://api.kilo.ai`.
There are also optional overrides for other services:
| Variable | Default | Purpose |
| ------------------------- | -------------------------------- | ----------------------------------------- |
| `KILO_API_URL` | `https://api.kilo.ai` | Kilo API (gateway, auth, models, profile) |
| `KILO_SESSION_INGEST_URL` | `https://ingest.kilosessions.ai` | Session export / cloud sync |
| `KILO_MODELS_URL` | `https://models.dev` | Model metadata |
> **VS Code:** The repo includes a "VSCode - Run Extension (Local Backend)" launch config in `.vscode/launch.json` that sets `KILO_API_URL=http://localhost:3000` automatically.
### Pull Request Expectations
- **Issue First Policy:** All PRs must reference an existing issue.
@@ -77,6 +105,10 @@ kilo web # Start server + open web interface
- **Logic Changes:** Explain how you verified it works.
- **PR Titles:** Follow conventional commit standards (`feat:`, `fix:`, `docs:`, etc.).
### Issue and PR Lifecycle
To keep our backlog manageable, we automatically close inactive issues and PRs after a period of inactivity. This isn't a judgment on quality — older items tend to lose context over time and we'd rather start fresh if they're still relevant. Feel free to reopen or create a new issue/PR if you're still working on something!
### Style Preferences
- **Functions:** Keep logic within a single function unless breaking it out adds clear reuse.
+43 -13
View File
@@ -1,15 +1,15 @@
<p align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code"><img src="https://img.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a>
<a href="https://x.com/kilocode"><img src="https://img.shields.io/badge/kilocode-000000?style=flat&logo=x&logoColor=white" alt="X (Twitter)"></a>
<a href="https://blog.kilo.ai"><img src="https://img.shields.io/badge/Blog-555?style=flat&logo=substack&logoColor=white" alt="Substack Blog"></a>
<a href="https://kilo.ai/discord"><img src="https://img.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://www.reddit.com/r/kilocode/"><img src="https://img.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit"></a>
<a href="https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code"><img src="https://raster.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace" height="20"></a>
<a href="https://x.com/kilocode"><img src="https://raster.shields.io/badge/kilocode-000000?style=flat&logo=x&logoColor=white" alt="X (Twitter)" height="20"></a>
<a href="https://blog.kilo.ai"><img src="https://raster.shields.io/badge/Blog-555?style=flat&logo=substack&logoColor=white" alt="Substack Blog" height="20"></a>
<a href="https://kilo.ai/discord"><img src="https://raster.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord" height="20"></a>
<a href="https://www.reddit.com/r/kilocode/"><img src="https://raster.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit" height="20"></a>
</p>
# 🚀 Kilo
> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.
> #1 on OpenRouter. 1.5M+ Kilo Coders. 25T+ tokens processed
> [#1 coding agent on OpenRouter](https://openrouter.ai/apps/category/coding). 1.5M+ Kilo Coders. 25T+ tokens processed
- ✨ Generate code from natural language
- ✅ Checks its own work
@@ -18,11 +18,7 @@
- ⚡ Inline autocomplete suggestions
- 🤖 Latest AI models
- 🎁 API keys optional
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.2
<p align="center">
<img src="https://media.githubusercontent.com/media/Kilo-Org/kilocode/main/kilo.gif" width="100%" />
</p>
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4
## Quick Links
@@ -42,10 +38,10 @@
## Get Started in Visual Studio Code
1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 with transparent pricing that matches provider rates exactly.
2. Create your account to access 500+ cutting-edge AI models including Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 with transparent pricing that matches provider rates exactly.
3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action:
[![Watch the video](https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg)](https://youtu.be/pqGfYXgrhig)
<a href="https://youtu.be/pqGfYXgrhig"><img src="https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg" alt="Watch the video" width="640" height="360"></a>
## Get Started with the CLI
@@ -59,6 +55,38 @@ npx @kilocode/cli
Then run `kilo` in any project directory to start.
<!-- kilocode_change start -->
### npm Install Note: Hidden `.kilo` File
On some systems and npm versions, installing `@kilocode/cli` can create a hidden `.kilo` file near the installed `kilo` command (for example in a global npm bin directory). This file is an npm-generated launcher helper, not project data.
- Why it exists: npm may create helper artifacts while wiring CLI executables.
- Size caveat: size can vary by platform, npm version, and install mode (symlink vs copied launcher), so a strict fixed size is not guaranteed.
- Safety: it is safe to leave in place. Do not edit it manually. Use your package manager's uninstall (`npm uninstall -g @kilocode/cli`) to remove install artifacts cleanly.
<!-- kilocode_change end -->
### Install from GitHub Releases (Optional)
Download the latest binary or source code from the [Releases page](https://github.com/Kilo-Org/kilocode/releases), use this quick guide:
- `kilo-<os>-<arch>.zip` is the CLI binary for your OS and CPU architecture on Windows and macOS. (`kilo-linux-<arch>.tar.gz` for Linux)
- `darwin` means macOS.
- `x64` is standard 64-bit Intel/AMD CPUs.
- `x64-baseline` is a compatibility build for older x64 CPUs(do not support AVX Instruction).
- `arm64` is ARM-based Linux/MacOS.
- `musl` is statically linked Linux build for Alpine/minimal Docker without glibc. Alpine/minimal Docker users should prefer the matching \*-musl asset.
- `kilo-vscode-*.vsix` is the VS Code extension package and not the CLI binary.
- `Source code` releases are for building from source, not normal installation.
For most users:
- **Windows (most PCs):** `kilo-windows-x64.zip`
- **macOS Apple Silicon:** `kilo-darwin-arm64.zip`
- **macOS Intel:** `kilo-darwin-x64.zip`
- **Linux x64:** `kilo-linux-x64.tar.gz`
- **Linux on ARM:** `kilo-linux-arm64.tar.gz`
### Autonomous Mode (CI/CD)
Use the `--auto` flag with `kilo run` to enable fully autonomous operation without user interaction. This is ideal for CI/CD pipelines and automated workflows:
@@ -74,6 +102,8 @@ kilo run --auto "run tests and fix any failures"
We welcome contributions from developers, writers, and enthusiasts!
To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests.
See [RELEASING.md](RELEASING.md) for the release process.
## Code of Conduct
Our community is built on respect, inclusivity, and collaboration. Please review our [Code of Conduct](/CODE_OF_CONDUCT.md) to understand the expectations for all contributors and community members.
+118
View File
@@ -0,0 +1,118 @@
# Releasing Kilo Code
Kilo Code uses a fully automated CI pipeline triggered via GitHub Actions `workflow_dispatch`. A single workflow handles version bumping, building all artifacts, publishing to every distribution channel, and updating package registries.
## How to Trigger a Release
1. Go to the [`publish` workflow](https://github.com/Kilo-Org/kilocode/actions/workflows/publish.yml) in GitHub Actions.
2. Click **"Run workflow"**.
3. Select the branch (typically `main`).
4. Fill in the inputs:
- **`bump`** (choice): `patch`, `minor`, or `major`. Determines how the version number is incremented.
- **`version`** (string, optional): Override the version explicitly instead of using `bump`. Leave empty to use the bump-based calculation.
> **⚠️ Do not fill in `version` unless you have a specific reason to.**
> The default behavior — leaving `version` empty and selecting a `bump` level — is almost always what you want. The automated bump logic computes the correct next version from the current state of the repo. Only use the `version` override for exceptional cases like skipping versions or publishing a pre-release (e.g. `1.5.0-beta.1`).
5. Click **"Run workflow"** to start the release.
## What Happens During a Release
The `publish.yml` workflow runs four jobs sequentially:
### 1. Version (`version`)
- Checks out the repo with full history (`fetch-depth: 0`).
- Runs `script/version.ts` to compute the next version based on the `bump` or `version` input.
- Generates release notes from the commit history since the last release.
- Creates a **draft** GitHub Release with the computed tag (e.g. `v1.2.3`) and release notes.
- Outputs the `version`, `release` (database ID), and `tag` for downstream jobs.
### 2. Build CLI (`build-cli`)
- Runs `packages/opencode/script/build.ts` to compile the Kilo CLI binary.
- Builds native binaries for **all supported platforms and architectures**:
- Linux: x64, arm64 (glibc and musl), plus baseline (non-AVX2) variants
- macOS: x64, arm64, plus baseline variants
- Windows: x64 (plus baseline variant), arm64
- Patches ELF interpreters on Linux binaries for broad compatibility.
- Creates platform archives (`.tar.gz` for Linux, `.zip` for macOS/Windows) and uploads them to the draft GitHub Release.
- Uploads the `dist/` directory as a workflow artifact (`kilo-cli`) for subsequent jobs.
### 3. Build VS Code Extension (`build-vscode`)
- Downloads the CLI artifacts from the previous job.
- Runs `packages/kilo-vscode/script/build.ts` to build VSIX packages for all target platforms:
- `linux-x64`, `linux-arm64`, `alpine-x64`, `alpine-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64`, `win32-arm64`
- Each VSIX bundles the platform-specific CLI binary.
- Uploads the VSIX files as a workflow artifact (`kilo-vscode`).
### 4. Publish (`publish`)
Downloads all build artifacts and publishes to every distribution channel:
#### Version Commit and Tagging
- Updates the `version` field in all `package.json` files across the monorepo.
- Updates the Zed extension manifest (`extension.toml`) with the new version.
- Rebuilds the TypeScript SDK (`packages/sdk/js`).
- Commits the version bump, tags the commit, and pushes to the repo.
- Promotes the draft GitHub Release to a published release.
#### CLI (`@kilocode/cli`)
- Publishes platform-specific binary packages to **npm** (e.g. `@kilocode/cli-linux-x64`, `@kilocode/cli-darwin-arm64`, etc.).
- Publishes the main `@kilocode/cli` package to **npm** with optional dependencies on the binary packages.
- Builds and pushes a multi-arch **Docker image** (`ghcr.io/kilo-org/kilo`) to GitHub Container Registry (linux/amd64 + linux/arm64).
#### SDK (`@kilocode/sdk`)
- Builds and publishes the TypeScript SDK to **npm**.
#### Plugin (`@kilocode/plugin`)
- Builds and publishes the plugin interface package to **npm**.
#### VS Code Extension
- Publishes platform-specific VSIX packages to the **VS Code Marketplace** via `vsce`.
- Uploads all VSIX files to the **GitHub Release** as assets.
#### Package Registries (stable releases only)
- **AUR (Arch Linux)**: Clones `kilo-bin` from the AUR, updates the `PKGBUILD` with new version and SHA256 checksums, and pushes.
- **Homebrew**: Clones `Kilo-Org/homebrew-tap`, updates the `kilo.rb` formula with new version, download URLs, and SHA256 checksums, and pushes.
## Prerequisites and Permissions
### Repository Access
- The workflow only runs in the `Kilo-Org/kilocode` repository (guarded by `if: github.repository == 'Kilo-Org/kilocode'`).
- You must have **write access** to the repository to trigger a `workflow_dispatch` event.
### Workflow Permissions
The workflow requires these GitHub token permissions:
- `id-token: write` -- for npm provenance attestation
- `contents: write` -- for creating releases, pushing tags, and uploading assets
- `packages: write` -- for publishing Docker images to GHCR
### Required Secrets
The following secrets must be configured in the repository:
| Secret | Purpose |
| ---------------------------- | ---------------------------------------------------------------- |
| `KILO_API_KEY` | Kilo API key used during version computation |
| `KILO_ORG_ID` | Kilo organization ID |
| `KILO_MAINTAINER_APP_ID` | GitHub App ID for the kilo-maintainer bot (used for git commits) |
| `KILO_MAINTAINER_APP_SECRET` | GitHub App secret for the kilo-maintainer bot |
| `NPM_TOKEN` | npm authentication token for publishing packages |
| `VSCE_TOKEN` | VS Code Marketplace personal access token |
| `OVSX_TOKEN` | Open VSX Registry token (currently unused but configured) |
| `AUR_KEY` | SSH private key for pushing to the AUR |
### Concurrency
The workflow uses concurrency control (`workflow-ref-bump/version`) to prevent parallel releases from conflicting.
+10 -6
View File
@@ -1,5 +1,11 @@
# Security
## IMPORTANT
We do not accept AI generated security reports. We receive a large number of
these and we absolutely do not have the resources to review them all. If you
submit one that will be an automatic ban from the project.
## Threat Model
### Overview
@@ -30,12 +36,10 @@ Server mode is opt-in only. When enabled, set `KILO_SERVER_PASSWORD` to require
# Reporting Security Issues
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
We value the contributions of the security research community and recognize the importance of a coordinated approach to vulnerability disclosure. If you have discovered a security vulnerability, we encourage you to let us know immediately. We welcome the opportunity to work with you to resolve the issue promptly.
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/Kilo-Org/kilocode/security/advisories/new) tab.
Please email your findings to [security@kilo.ai](mailto:security@kilo.ai). We will acknowledge your report and work with you to resolve the issue.
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
## Escalation
If you do not receive an acknowledgement of your report within 6 business days, you may send an email to hi@kilo.ai
For more details, see our [Security Disclosure](https://kilo.ai/security) page.
+2934 -793
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1771207753,
"narHash": "sha256-b9uG8yN50DRQ6A7JdZBfzq718ryYrlmGgqkRm9OOwCE=",
"lastModified": 1775823930,
"narHash": "sha256-ALT447J7FcxP/97J01A/gp/hgdO5lXRsm+zLMt+gIjc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d1c15b7d5806069da59e819999d70e1cec0760bf",
"rev": "8c11f88bb9573a10a7d6bf87161ef08455ac70b9",
"type": "github"
},
"original": {
+46 -18
View File
@@ -22,7 +22,7 @@
default =
let
kilo-dev = pkgs.writeShellScriptBin "kilo-dev" ''
cd "$KILO_ROOT"
cd "$KILO_ROOT"
exec ${pkgs.bun}/bin/bun dev "$@"
'';
@@ -150,27 +150,55 @@
'';
in
pkgs.mkShell {
packages = with pkgs; [
bun
nodejs_20
pkg-config
openssl
git
gh
playwright-driver.browsers
vsce
unzip
gnutar
gzip
ripgrep
kilo-dev
kilo-install-bin
kilo-bin
];
packages =
with pkgs;
[
bun
nodejs_20
python3
pkg-config
openssl
git
gh
playwright-driver.browsers
vsce
unzip
gnutar
gzip
patchelf
ripgrep
jetbrains.jdk
jdk21
kilo-dev
kilo-install-bin
kilo-bin
]
++ lib.optionals stdenv.isLinux [
libX11
libXext
libXrender
libXtst
libXi
fontconfig
freetype
];
shellHook = ''
export KILO_ROOT="$PWD"
export PLAYWRIGHT_BROWSERS_PATH="${pkgs.playwright-driver.browsers}"
export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true
''
+ pkgs.lib.optionalString pkgs.stdenv.isLinux ''
export LD_LIBRARY_PATH="${
pkgs.lib.makeLibraryPath [
pkgs.libX11
pkgs.libXext
pkgs.libXrender
pkgs.libXtst
pkgs.libXi
pkgs.fontconfig
pkgs.freetype
]
}:$LD_LIBRARY_PATH"
'';
};
});
+2 -2
View File
@@ -91,7 +91,7 @@ This will walk you through installing the KiloConnect GitHub app, creating the w
persist-credentials: false
- name: Run Kilo
uses: Kilo-Org/kilo/github@latest
uses: Kilo-Org/kilocode/github@latest
with:
model: kilo/claude-sonnet-4-20250514
kilo_api_key: ${{ secrets.KILO_API_KEY }}
@@ -120,7 +120,7 @@ You can also use other AI providers by setting their API keys:
```yml
- name: Run Kilo
uses: Kilo-Org/kilo/github@latest
uses: Kilo-Org/kilocode/github@latest
with:
model: anthropic/claude-sonnet-4-20250514
env:
+6 -1
View File
@@ -30,6 +30,10 @@ inputs:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/kilo,/kc'"
required: false
variant:
description: "Model variant for provider-specific reasoning effort (e.g., high, max, minimal)"
required: false
oidc_base_url:
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.kilo.ai"
required: false
@@ -49,7 +53,7 @@ runs:
id: version
shell: bash
run: |
VERSION=$(curl -sf https://api.github.com/repos/Kilo-Org/kilo/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
VERSION=$(curl -sf https://api.github.com/repos/Kilo-Org/kilocode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT
- name: Cache Kilo
@@ -79,6 +83,7 @@ runs:
PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
KILO_API_KEY: ${{ inputs.kilo_api_key }}
KILO_ORG_ID: ${{ inputs.kilo_org_id }}
+1 -1
View File
@@ -184,7 +184,7 @@ else
if [ -z "$requested_version" ]; then
url="https://github.com/Kilo-Org/kilocode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/Kilo-Org/kilo/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
specific_version=$(curl -s https://api.github.com/repos/Kilo-Org/kilocode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-3bPGhBGlOCNhahLNyZF51i9bI1G2LWtGbGrKPnutJtA=",
"aarch64-linux": "sha256-5NMz7CdxX2LHh8Rn4MpHez7PBSiA8He6kM2cReiNy6c=",
"aarch64-darwin": "sha256-hb3/Ylt/GIXW6aT1PWbpKzYXnjSlOECOZJRVZnB9TvQ=",
"x86_64-darwin": "sha256-SrMMyUzkhUSa37N4E3aBQ+S0rixt7B+6eGngByPSRTk="
"x86_64-linux": "sha256-wRgRi939a/0RPCKthLccnBjYbiRcWhCOhbHd0HgyUhs=",
"aarch64-linux": "sha256-oYqoGtXLp9/GE5yU9l6RLoK8sVLxZKgp1BMcpmDhFe0=",
"aarch64-darwin": "sha256-zTpY9iM/Vp/OmQ35rybhRgoN7neXzNeugZD+0a5O+LU=",
"x86_64-darwin": "sha256-H/5W9FKEK/N9m11+N0sIZgr9LMQuhKaErrxYyQBEhtM="
}
}
+10 -3
View File
@@ -3,6 +3,7 @@
stdenvNoCC,
callPackage,
bun,
nodejs,
sysctl,
makeBinaryWrapper,
models-dev,
@@ -19,6 +20,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nativeBuildInputs = [
bun
nodejs # for patchShebangs node_modules
installShellFiles
makeBinaryWrapper
models-dev
@@ -29,12 +31,14 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
patchShebangs node_modules
patchShebangs packages/*/node_modules
runHook postConfigure
'';
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
env.OPENCODE_DISABLE_MODELS_FETCH = true;
env.KILO_DISABLE_MODELS_FETCH = true;
env.KILO_VERSION = finalAttrs.version;
env.KILO_CHANNEL = "local";
@@ -80,7 +84,10 @@ stdenvNoCC.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
];
doInstallCheck = true;
versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ];
versionCheckKeepEnvironment = [
"HOME"
"KILO_DISABLE_MODELS_FETCH"
];
versionCheckProgramArg = "--version";
passthru = {
@@ -88,7 +95,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
};
meta = {
description = "The open source coding agent";
description = "AI-powered development tool";
homepage = "https://kilo.ai/";
license = lib.licenses.mit;
mainProgram = "kilo";
+2 -1
View File
@@ -20,7 +20,7 @@ let
in
stdenvNoCC.mkDerivation {
pname = "kilo-node_modules";
version = "${packageJson.version}-${rev}";
version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
src = lib.fileset.toSource {
root = ../.;
@@ -53,6 +53,7 @@ stdenvNoCC.mkDerivation {
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \
--ignore-scripts \
--no-progress
+51 -21
View File
@@ -4,16 +4,19 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.9",
"packageManager": "bun@1.3.11",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
"test": "echo 'do not run tests from root' && exit 1"
"test": "echo 'do not run tests from root' && exit 1",
"extension": "bun --cwd packages/kilo-vscode script/launch.ts"
},
"workspaces": {
"packages": [
@@ -21,7 +24,9 @@
"packages/sdk/js"
],
"catalog": {
"@types/bun": "1.3.9",
"@effect/platform-node": "4.0.0-beta.43",
"@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -33,52 +38,61 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.13",
"@pierre/diffs": "1.1.0-beta.18",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"ai": "5.0.124",
"hono": "4.10.7",
"diff": "8.0.4",
"dompurify": "3.3.3",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.43",
"ai": "6.0.138",
"cross-spawn": "7.0.6",
"hono": "4.12.12",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.51.0",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"@typescript/native-preview": "7.0.0-dev.20260316.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"shiki": "3.20.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"vite": "7.1.4",
"vite": "7.3.2",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10",
"solid-js": "1.9.12",
"vite-plugin-solid": "2.11.10"
}
},
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.17.23",
"turbo": "2.5.6"
"sst": "3.18.10",
"turbo": "2.8.13",
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "^2.27.10"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@aws-sdk/client-s3": "3.1025.0",
"@kilocode/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@kilocode/sdk": "workspace:*",
"typescript": "catalog:"
"typescript": "catalog:",
"@morphllm/morphsdk": "0.2.141"
},
"repository": {
"type": "git",
@@ -94,16 +108,32 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"web-tree-sitter"
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:"
"@types/node": "catalog:",
"effect": "catalog:",
"@effect/platform-node-shared": "4.0.0-beta.35",
"path-to-regexp": ">=8.4.0",
"picomatch": ">=2.3.2",
"defu": "6.1.6",
"lodash": "4.18.1",
"@xmldom/xmldom": ">=0.8.12",
"smol-toml": ">=1.6.1",
"fastify": ">=5.8.3",
"diff": "8.0.4",
"dompurify": "3.3.3",
"happy-dom": ">=20.8.9"
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
},
"version": "1.0.25",
"version": "7.2.6",
"peerDependencies": {}
}
@@ -0,0 +1,515 @@
# CreateEffect Simplification Implementation Spec
Reduce reactive misuse across `packages/app`.
---
## Context
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
Key issues from the audit:
- Derived state is being written through effects instead of computed directly
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
- User-driven actions are hidden inside reactive effects
- Context layers mirror and hydrate child stores with multiple sync effects
- Several areas repeat the same imperative trigger pattern in multiple effects
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
## Goals
- Cut high-churn `createEffect` usage in the hottest files first
- Replace effect-driven derived state with reactive derivation
- Replace reset-on-key effects with keyed ownership boundaries
- Move event-driven work to direct actions and write paths
- Remove mirrored store hydration where a single source of truth can exist
- Leave necessary external sync effects in place, but make them narrower and clearer
## Non-Goals
- Do not rewrite unrelated component structure just to reduce the count
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
- Do not attempt a repo-wide cleanup outside `packages/app`
## Effect Taxonomy And Replacement Rules
Use these rules during implementation.
### Prefer `createMemo`
Use `createMemo` when the target value is pure derived state from other signals or stores.
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
Apply this to:
- `packages/app/src/pages/session.tsx:141`
- `packages/app/src/pages/layout.tsx:557`
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Rules:
- If no external system is touched, do not use `createEffect`
- Derive once, then read the memo where needed
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
### Prefer Keyed Remounts
Use keyed remounts when local UI state should reset because an identity changed.
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
Apply this to:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
- `packages/app/src/context/file.tsx:100`
Rules:
- If the desired behavior is "new identity, fresh local state," key the owner subtree
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
### Prefer Event Handlers And Actions
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
Apply this to:
- `packages/app/src/pages/layout.tsx:484`
- `packages/app/src/pages/layout.tsx:652`
- `packages/app/src/pages/layout.tsx:776`
- `packages/app/src/pages/layout.tsx:1489`
- `packages/app/src/pages/layout.tsx:1519`
- `packages/app/src/components/file-tree.tsx:328`
- `packages/app/src/pages/session/terminal-panel.tsx:55`
- `packages/app/src/context/global-sync.tsx:148`
- Duplicated trigger sets in:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Rules:
- If the trigger is user intent, call the action at the source of that intent
- If the same imperative work is triggered from multiple places, extract one function and call it directly
### Prefer `onMount` And `onCleanup`
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
Use this when:
- Setup should happen once per owner lifecycle
- Cleanup should always pair with teardown
- The work is not conceptually derived state
### Keep `createEffect` When It Is A Real Bridge
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
Examples that should remain, though they may be narrowed or split:
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
- Scroll sync in `packages/app/src/pages/session.tsx:685`
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- External sync in:
- `packages/app/src/context/language.tsx:207`
- `packages/app/src/context/settings.tsx:110`
- `packages/app/src/context/sdk.tsx:26`
- Polling in:
- `packages/app/src/components/status-popover.tsx:59`
- `packages/app/src/components/dialog-select-server.tsx:273`
Rules:
- Keep the effect single-purpose
- Make dependencies explicit and narrow
- Avoid writing back into the same reactive graph unless absolutely required
## Implementation Plan
### Phase 0: Classification Pass
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
Acceptance criteria:
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
- Shared helpers to be introduced are identified up front to avoid repeating patterns
### Phase 1: Derived-State Cleanup
Tackle highest-value, lowest-risk derived-state cleanup first.
Priority items:
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
- Replace other obvious derived-state effects in terminal and session header
Acceptance criteria:
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
- Targeted derived-state effects are deleted, not just moved
### Phase 2: Keyed Reset Cleanup
Replace reset-on-key effects with keyed ownership boundaries.
Priority items:
- Key session-scoped UI and state by `sessionKey`
- Key file-scoped state by `scope()`
- Remove manual clear-and-reseed effects in session and file context
Acceptance criteria:
- Switching session or file scope recreates the intended local state cleanly
- No stale state leaks across session or scope changes
- Target reset effects are deleted
### Phase 3: Event-Driven Work Extraction
Move event-driven work out of reactive effects.
Priority items:
- Replace `globalStore.reload` effect dispatching with direct calls
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
- Collapse duplicated imperative trigger triplets into single functions
- Move file-tree and terminal-panel imperative work to explicit handlers
Acceptance criteria:
- User-triggered behavior still fires exactly once per intended action
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
### Phase 4: Context Ownership Cleanup
Remove mirrored child-store hydration patterns.
Priority items:
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
Acceptance criteria:
- There is one clear source of truth for each synced value
- Child stores no longer need effect-based hydration to stay consistent
- Initialization and updates both work without manual mirror effects
### Phase 5: Cleanup And Keeper Review
Clean up remaining targeted hotspots and narrow the effects that should stay.
Acceptance criteria:
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
- Mixed-responsibility effects are split into smaller units where still needed
## Detailed Work Items By Area
### 1. Normalize Tab State
Files:
- `packages/app/src/pages/session.tsx:141`
Work:
- Move tab normalization into the functions that create, load, or update tab state
- Make readers consume already-normalized tab data
- Remove the effect that rewrites derived tab state after the fact
Rationale:
- Tabs should become valid when written, not be repaired later
- This removes a feedback loop and makes state easier to trust
Acceptance criteria:
- The effect at `packages/app/src/pages/session.tsx:141` is removed
- Newly created and restored tabs are normalized before they enter local state
- Tab rendering still matches current behavior for valid and edge-case inputs
### 2. Key Session-Owned State
Files:
- `packages/app/src/pages/session.tsx:325`
- `packages/app/src/pages/session.tsx:336`
- `packages/app/src/pages/session.tsx:477`
- `packages/app/src/pages/session.tsx:869`
- `packages/app/src/pages/session.tsx:963`
- `packages/app/src/pages/session/message-timeline.tsx:149`
Work:
- Identify state that should reset when `sessionKey` changes
- Move that state under a keyed subtree or keyed owner boundary
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
Rationale:
- Session identity already defines the lifetime of this UI state
- Keyed ownership makes reset behavior automatic and easier to reason about
Acceptance criteria:
- The targeted reset effects are removed
- Changing sessions resets only the intended session-local state
- Scroll and editor state that should persist are not accidentally reset
### 3. Derive Workspace Order
Files:
- `packages/app/src/pages/layout.tsx:557`
Work:
- Stop writing `workspaceOrder` from live workspace data in an effect
- Represent user overrides separately from live workspace data
- Compute effective order from current data plus overrides with a memo or pure helper
Rationale:
- Persisted user intent and live source data should not mirror each other through an effect
- A computed effective order avoids drift and racey resync behavior
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
- User overrides persist without requiring a sync-back effect
### 4. Remove Child-Store Mirrors
Files:
- `packages/app/src/context/global-sync.tsx:130`
- `packages/app/src/context/global-sync.tsx:138`
- `packages/app/src/context/global-sync.tsx:148`
- `packages/app/src/context/global-sync/child-store.ts:184`
- `packages/app/src/context/global-sync/child-store.ts:190`
- `packages/app/src/context/global-sync/child-store.ts:193`
- `packages/app/src/context/layout.tsx:424`
Work:
- Trace the actual ownership of global and child store values
- Replace hydration and mirror effects with explicit initialization and direct updates
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
Rationale:
- Mirrors make it hard to tell which state is authoritative
- Event-bus style state toggles hide control flow and create accidental reruns
Acceptance criteria:
- Child store hydration no longer depends on effect-based copying
- Reload work can be followed from the event source to the handler without a reactive relay
- State remains correct on first load, child creation, and subsequent updates
### 5. Key File-Scoped State
Files:
- `packages/app/src/context/file.tsx:100`
Work:
- Move file-scoped local state under a boundary keyed by `scope()`
- Remove any effect that watches `scope()` only to reset file-local state
Rationale:
- File scope changes are identity changes
- Keyed ownership gives a cleaner reset than manual clear logic
Acceptance criteria:
- The effect at `packages/app/src/context/file.tsx:100` is removed
- Switching scopes resets only scope-local state
- No previous-scope data appears after a scope change
### 6. Split Layout Side Effects
Files:
- `packages/app/src/pages/layout.tsx:1489`
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
Work:
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
- Move user-triggered branches into the actual command or handler that causes them
- Remove any branch that only exists because one effect is handling unrelated concerns
Rationale:
- Mixed effects hide cause and make reruns hard to predict
- Smaller units reduce accidental coupling and make future cleanup safer
Acceptance criteria:
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
- Event-driven branches execute from direct handlers
- Remaining effects in this area each have one clear external sync purpose
### 7. Remove Duplicate Triggers
Files:
- `packages/app/src/pages/session/review-tab.tsx:122`
- `packages/app/src/pages/session/review-tab.tsx:130`
- `packages/app/src/pages/session/review-tab.tsx:138`
- `packages/app/src/pages/session/file-tabs.tsx:367`
- `packages/app/src/pages/session/file-tabs.tsx:378`
- `packages/app/src/pages/session/file-tabs.tsx:389`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
Work:
- Extract one explicit imperative function per behavior
- Call that function from each source event instead of replicating the same effect pattern multiple times
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
Rationale:
- Duplicate triggers make it easy to miss a case or fire twice
- One named action is easier to test and reason about
Acceptance criteria:
- Repeated imperative effect triplets are collapsed into shared functions
- Scroll behavior still works, including hash-based navigation
- No duplicate firing is introduced
### 8. Make Prompt Filtering Reactive
Files:
- `packages/app/src/components/prompt-input.tsx:652`
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
Work:
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
Rationale:
- Filtering is classic derived state
- It should not need an effect if it can be computed from current inputs
Acceptance criteria:
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
- Filtered slash-command results update correctly as the input changes
- The editor sync effect at `:690` still behaves correctly
### 9. Clean Up Smaller Derived-State Cases
Files:
- `packages/app/src/components/terminal.tsx:261`
- `packages/app/src/components/session/session-header.tsx:309`
Work:
- Replace effect-written local state with memos or inline derivation
- Remove intermediate setters when the value can be computed directly
Rationale:
- These are low-risk wins that reinforce the same pattern
- They also help keep follow-up cleanup consistent
Acceptance criteria:
- Targeted effects are removed
- UI output remains unchanged under the same inputs
## Verification And Regression Checks
Run focused checks after each phase, not only at the end.
### Suggested Verification
- Switch between sessions rapidly and confirm local session UI resets only where intended
- Open, close, and reorder tabs and confirm order and normalization remain stable
- Change workspaces, reload workspace data, and verify effective ordering is correct
- Change file scope and confirm stale file state does not bleed across scopes
- Trigger layout actions that previously depended on effects and confirm they still fire once
- Use slash commands in the prompt and verify filtering updates as you type
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
- Verify global sync initialization, reload, and child-store creation paths
### Regression Checks
- No accidental infinite reruns
- No double-firing network or command actions
- No lost cleanup for listeners, timers, or scroll handlers
- No preserved stale state after identity changes
- No removed effect that was actually bridging to DOM or an external API
If available, add or update tests around pure helpers introduced during this cleanup.
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
## Definition Of Done
This work is done when all of the following are true:
- The highest-leverage targets in this spec are implemented
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
- The "should remain" effects still exist only where they serve a real external sync purpose
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
- No behavior regressions are found in the targeted areas
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
The main success metric is clearer ownership and fewer effect-driven state repairs.
## Risks And Rollout Notes
Main risks:
- Keyed remounts can reset too much if state boundaries are drawn too high
- Store mirror removal can break initialization order if ownership is not mapped first
- Moving event work out of effects can accidentally skip triggers that were previously implicit
Rollout notes:
- Land in small phases, with each phase keeping the app behaviorally stable
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
+55 -6
View File
@@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
### Using Fixtures
- `page` - Playwright page
- `sdk` - OpenCode SDK client for API calls
- `gotoSession(sessionID?)` - Navigate to session
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
### Helper Functions
@@ -70,7 +72,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
- `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
- `withSession(sdk, title, callback)` - Create temp session
- `sessionIDFromUrl(url)` - Read session ID from URL
- `slugFromUrl(url)` - Read workspace slug from URL
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
- `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`):
@@ -109,7 +116,7 @@ import { test, expect } from "@playwright/test"
### Error Handling
Tests should clean up after themselves:
Tests should clean up after themselves. Prefer fixture-managed cleanup:
```typescript
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
@@ -120,6 +127,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
})
```
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
- Avoid calling `sdk.session.delete(...)` directly
### Timeouts
Default: 60s per test, 10s per assertion. Override when needed:
@@ -156,14 +168,51 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings
```
### Terminal Tests
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
### Wait on state
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
- Avoid race-prone flows that assume work is finished after an action
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
- Do not treat a visible element as proof that the app will route the next action to it
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
### Add hooks
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
### Prefer helpers
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
- Use direct locators when the interaction is simple and a helper would not add clarity
- Prefer helpers that both perform an action and verify the app consumed it
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
## Writing New Tests
1. Choose appropriate folder or create new one
2. Import from `../fixtures`
3. Use helper functions from `../actions` and `../selectors`
4. Clean up any created resources
5. Use specific selectors (avoid CSS classes)
6. Test one feature per test file
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
5. Clean up any created resources
6. Use specific selectors (avoid CSS classes)
7. Test one feature per test file
## Local Development
+661 -135
View File
@@ -1,24 +1,37 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { modKey, serverUrl } from "./utils"
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
sessionItemSelector,
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector,
popoverBodySelector,
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
promptSelector,
terminalSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
import type { createSdk } from "./utils"
const phase = new WeakMap<Page, "test" | "cleanup">()
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
phase.set(page, value)
}
export function healthPhase(page: Page) {
return phase.get(page) ?? "test"
}
export async function defocus(page: Page) {
await page
@@ -29,9 +42,141 @@ export async function defocus(page: Page) {
.catch(() => undefined)
}
export async function openPalette(page: Page) {
async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr)
if (id) return id
throw new Error(`Active terminal missing ${terminalAttr}`)
}
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
return page.evaluate((id) => {
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
}, id)
}
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const id = await terminalID(term)
await page.evaluate((id) => {
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
}, id)
}
async function terminalReady(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return !!state?.connected && (state.settled ?? 0) > 0
}, id)
}
async function terminalFocusIdle(page: Page, term?: Locator) {
const next = term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
return (state?.focusing ?? 0) === 0
}, id)
}
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
const next = input.term ?? page.locator(terminalSelector).first()
const id = await terminalID(next)
return page.evaluate(
(input) => {
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
return state?.rendered.includes(input.token) ?? false
},
{ id, token: input.token },
)
}
async function promptSlashActive(page: Page, id: string) {
return page.evaluate((id) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (state?.popover !== "slash") return false
if (!state.slash.ids.includes(id)) return false
return state.slash.active === id
}, id)
}
async function promptSlashSelects(page: Page) {
return page.evaluate(() => {
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
})
}
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
return page.evaluate((input) => {
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
if (!state) return false
return state.selected === input.id && state.selects >= input.count
}, input)
}
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await expect(term).toBeVisible()
await expect(term.locator("textarea")).toHaveCount(1)
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
}
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
const term = input?.term ?? page.locator(terminalSelector).first()
const timeout = input?.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
}
export async function showPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
await expect
.poll(
async () => {
await prompt.click().catch(() => false)
await prompt.fill(input.text).catch(() => false)
return promptSlashActive(page, input.id).catch(() => false)
},
{ timeout },
)
.toBe(true)
}
export async function runPromptSlash(
page: Page,
input: { id: string; text: string; prompt?: Locator; timeout?: number },
) {
const prompt = input.prompt ?? page.locator(promptSelector)
const timeout = input.timeout ?? 10_000
const count = await promptSlashSelects(page)
await showPromptSlash(page, input)
await prompt.press("Enter")
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
}
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
const term = input.term ?? page.locator(terminalSelector).first()
const timeout = input.timeout ?? 10_000
await waitTerminalReady(page, { term, timeout })
const textarea = term.locator("textarea")
await term.click()
await expect(textarea).toBeFocused()
await page.keyboard.type(input.cmd)
await page.keyboard.press("Enter")
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
}
export async function openPalette(page: Page, key = "K") {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
await page.keyboard.press(`${modKey}+${key}`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
@@ -60,10 +205,50 @@ export async function closeDialog(page: Page, dialog: Locator) {
await expect(dialog).toHaveCount(0)
}
export async function isSidebarClosed(page: Page) {
const main = page.locator("main")
const classes = (await main.getAttribute("class")) ?? ""
return classes.includes("xl:border-l")
async function isSidebarClosed(page: Page) {
const button = await waitSidebarButton(page, "isSidebarClosed")
return (await button.getAttribute("aria-expanded")) !== "true"
}
async function errorBoundaryText(page: Page) {
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
if (!(await title.isVisible().catch(() => false))) return
const description = await page
.getByText(/an error occurred while loading the application\./i)
.first()
.textContent()
.catch(() => "")
const detail = await page
.getByRole("textbox", { name: /error details/i })
.first()
.inputValue()
.catch(async () =>
(
(await page
.getByRole("textbox", { name: /error details/i })
.first()
.textContent()
.catch(() => "")) ?? ""
).trim(),
)
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)
throw new Error(`Error boundary during ${context}\n${text}`)
}
async function waitSidebarButton(page: Page, context: string) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
await assertHealthy(page, context)
return button
}
export async function toggleSidebar(page: Page) {
@@ -74,52 +259,39 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
const button = await waitSidebarButton(page, "openSidebar")
await button.click()
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const opened = await expect(main)
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
const opened = await expect(button)
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) return
await toggleSidebar(page)
await expect(main).not.toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "true")
}
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
const button = await waitSidebarButton(page, "closeSidebar")
await button.click()
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const closed = await expect(main)
.toHaveClass(/xl:border-l/, { timeout: 1500 })
const closed = await expect(button)
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await toggleSidebar(page)
await expect(main).toHaveClass(/xl:border-l/)
await expect(button).toHaveAttribute("aria-expanded", "false")
}
export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page)
const dialog = page.getByRole("dialog")
@@ -132,82 +304,174 @@ export async function openSettings(page: Page) {
if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
return dialog
}
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
const list = Array.isArray(store.list) ? store.list : []
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) }
const add = (origin: string, directory: string) => {
const current = nextProjects[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === directory)) return
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
}
const directories = [args.directory, ...args.extra]
for (const directory of directories) {
add("local", directory)
add(args.serverUrl, directory)
}
localStorage.setItem(
key,
JSON.stringify({
list,
projects: nextProjects,
lastProject,
}),
)
},
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
)
}
export async function createTestProject() {
export async function createTestProject(input?: { serverUrl?: string }) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root,
stdio: "ignore",
})
return root
return resolveDirectory(root, input?.serverUrl)
}
export async function cleanupTestProject(directory: string) {
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
try {
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
} catch {}
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
}
export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
}
async function probeSession(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const current = win.__opencode_e2e?.model?.current
if (!current) return null
return { dir: current.dir, sessionID: current.sessionID }
})
.catch(() => null as { dir?: string; sessionID?: string } | null)
}
export async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
let next = ""
await expect
.poll(
async () => {
await assertHealthy(page, "waitSlug")
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
if (slug !== prev) {
prev = slug
next = ""
return ""
}
next = slug
return slug
},
{ timeout: 45_000 },
)
.not.toBe("")
return next
}
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory, input?.serverUrl)
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
}
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
const target = await resolveDirectory(directory, input?.serverUrl)
await expect
.poll(
async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug, input)
.then((item) => item.directory)
.catch(() => "")
},
{ timeout: 45_000 },
)
.toBe(target)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSession(
page: Page,
input: {
directory: string
sessionID?: string
serverUrl?: string
allowAnySession?: boolean
},
) {
const target = await resolveDirectory(input.directory, input.serverUrl)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false
if (!input.sessionID && !input.allowAnySession && current) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
if (dir !== target) return false
}
return page
.locator(promptSelector)
.first()
.isVisible()
.catch(() => false)
},
{ timeout: 45_000 },
)
.toBe(true)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
const sdk = createSdk(directory, serverUrl)
const target = await resolveDirectory(directory, serverUrl)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
},
{ timeout },
)
.toBe(target)
await expect
.poll(
async () => {
const items = await sdk.session
.messages({ sessionID, limit: 20 })
.then((x) => x.data ?? [])
.catch(() => [])
return items.some((item) => item.info.role === "user")
},
{ timeout },
)
.toBe(true)
}
export function sessionIDFromUrl(url: string) {
@@ -216,7 +480,7 @@ export function sessionIDFromUrl(url: string) {
}
export async function hoverSessionItem(page: Page, sessionID: string) {
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
await expect(sessionEl).toBeVisible()
await sessionEl.hover()
return sessionEl
@@ -225,7 +489,7 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const scroller = page.locator(".session-scroller").first()
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
@@ -270,12 +534,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
}
export async function openSharePopover(page: Page) {
const rightSection = page.locator(titlebarRightSelector)
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
await expect(shareButton).toBeVisible()
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
const popoverBody = page
.locator(popoverBodySelector)
.locator('[data-component="popover-content"]')
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
.first()
@@ -285,16 +552,13 @@ export async function openSharePopover(page: Page) {
.catch(() => false)
if (!opened) {
await shareButton.click()
await expect(popoverBody).toBeVisible()
const menu = page.locator(dropdownMenuContentSelector).first()
await menuTrigger.click()
await clickMenuItem(menu, /share/i)
await expect(menu).toHaveCount(0)
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
}
return { rightSection, popoverBody }
}
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
await expect(button).toBeVisible()
await button.click()
return { rightSection: scroller, popoverBody }
}
export async function clickListItem(
@@ -320,6 +584,58 @@ export async function clickListItem(
return item
}
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const data = await sdk.session
.status()
.then((x) => x.data ?? {})
.catch(() => undefined)
return data?.[sessionID]
}
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
let prev = ""
await expect
.poll(
async () => {
const info = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!info) return true
const next = `${info.title}:${info.time.updated ?? info.time.created}`
if (next !== prev) {
prev = next
return false
}
return true
},
{ timeout },
)
.toBe(true)
}
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
}
export async function cleanupSession(input: {
sessionID: string
directory?: string
sdk?: ReturnType<typeof createSdk>
serverUrl?: string
}) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined)
if (current && current.type !== "idle") {
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
}
await stable(sdk, input.sessionID).catch(() => undefined)
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
}
export async function withSession<T>(
sdk: ReturnType<typeof createSdk>,
title: string,
@@ -331,10 +647,161 @@ export async function withSession<T>(
try {
return await callback(session)
} finally {
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
await cleanupSession({ sdk, sessionID: session.id })
}
}
const seedSystem = [
"You are seeding deterministic e2e UI state.",
"Follow the user's instruction exactly.",
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
"Do not call any extra tools.",
].join(" ")
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
const timeout = input.timeout ?? 30_000
const end = Date.now() + timeout
while (Date.now() < end) {
const value = await input.probe()
if (value !== undefined) return value
await new Promise((resolve) => setTimeout(resolve, 250))
}
}
const seed = async <T>(input: {
sessionID: string
prompt: string
sdk: ReturnType<typeof createSdk>
probe: () => Promise<T | undefined>
timeout?: number
attempts?: number
}) => {
for (let i = 0; i < (input.attempts ?? 2); i++) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
agent: "build",
system: seedSystem,
parts: [{ type: "text", text: input.prompt }],
})
const value = await wait({ probe: input.probe, timeout: input.timeout })
if (value !== undefined) return value
}
}
export async function seedSessionQuestion(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
questions: Array<{
header: string
question: string
options: Array<{ label: string; description: string }>
multiple?: boolean
custom?: boolean
}>
},
) {
const first = input.questions[0]
if (!first) throw new Error("Question seed requires at least one question")
const text = [
"Your only valid response is one question tool call.",
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
"Do not output plain text.",
"After calling the tool, wait for the user response.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.question.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
},
})
if (!result) throw new Error("Timed out seeding question request")
return { id: result.id }
}
export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
description: string
prompt: string
subagentType?: string
},
) {
const text = [
"Your only valid response is one task tool call.",
`Use this JSON input: ${JSON.stringify({
description: input.description,
prompt: input.prompt,
subagent_type: input.subagentType ?? "general",
})}`,
"Do not output plain text.",
"Wait for the task to start and return the child session id.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 90_000,
probe: async () => {
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
const part = messages
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
return false
if (!("sessionId" in part.state.metadata)) return false
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
if (!("sessionId" in part.state.metadata)) return
const id = part.state.metadata.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined)
if (!child?.id) return
return { sessionID: id }
},
})
if (!result) throw new Error("Timed out seeding task tool")
return result
}
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []),
sdk.permission.list().then((x) => x.data ?? []),
])
await Promise.all([
...questions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
...permissions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
])
return true
}
export async function openStatusPopover(page: Page) {
await defocus(page)
@@ -358,56 +825,105 @@ export async function openStatusPopover(page: Page) {
}
export async function openProjectMenu(page: Page, projectSlug: string) {
await openSidebar(page)
const item = page.locator(projectSwitchSelector(projectSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
.first()
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ force: true, timeout: 1500 })
.then(() => true)
.catch(() => false)
if (clicked) {
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
await expect(close).toBeVisible()
return menu
}
}
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator(dropdownMenuContentSelector).first()
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
await expect(close).toBeVisible()
return menu
}
await trigger.click({ force: true })
await expect(menu).toBeVisible()
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
throw new Error(`Failed to open project menu: ${projectSlug}`)
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
const current = await page
.getByRole("button", { name: "New workspace" })
.first()
.isVisible()
.then((x) => x)
.catch(() => false)
const current = () =>
page
.getByRole("button", { name: "New workspace" })
.first()
.isVisible()
.then((x) => x)
.catch(() => false)
if (current === enabled) return
if ((await current()) === enabled) return
await openProjectMenu(page, projectSlug)
if (enabled) {
await page.reload()
await openSidebar(page)
if ((await current()) === enabled) return
}
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
await page.waitForTimeout(100) // kilocode_change - wait before toggle click
await toggle.click({ force: true })
const flip = async (timeout?: number) => {
const menu = await openProjectMenu(page, projectSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeEnabled({ timeout: 30_000 })
const clicked = await toggle
.click({ force: true, timeout })
.then(() => true)
.catch(() => false)
if (clicked) return
await toggle.focus()
await page.keyboard.press("Enter")
}
for (const timeout of [1500, undefined, undefined]) {
if ((await current()) === enabled) break
await flip(timeout)
.then(() => undefined)
.catch(() => undefined)
const matched = await expect
.poll(current, { timeout: 5_000 })
.toBe(enabled)
.then(() => true)
.catch(() => false)
if (matched) break
}
if ((await current()) !== enabled) {
await page.reload()
await openSidebar(page)
}
const expected = enabled ? "New workspace" : "New session"
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
}
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
@@ -425,3 +941,13 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
await expect(menu).toBeVisible()
return menu
}
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
}
+6 -3
View File
@@ -1,17 +1,20 @@
import { test, expect } from "../fixtures"
import { serverName } from "../utils"
import { serverNamePattern } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
await expect(nav.getByText("No projects open")).toBeVisible()
await expect(nav.getByText("Open a project to get started")).toBeVisible()
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
})
test("server picker dialog opens from home", async ({ page }) => {
await page.goto("/")
const trigger = page.getByRole("button", { name: serverName })
const trigger = page.getByRole("button", { name: serverNamePattern })
await expect(trigger).toBeVisible()
await trigger.click()
+10 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"
import { closeDialog, openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})
+11 -8
View File
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { serverName, serverUrl } from "../utils"
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
import { serverNamePattern, serverUrls } from "../utils"
import { closeDialog, clickMenuItem } from "../actions"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
await expect(row).toBeVisible()
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click({ force: true })
@@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
await expect(menu).toBeVisible()
await clickMenuItem(menu, /set as default/i)
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
await expect(row.getByText("Default", { exact: true })).toBeVisible()
await expect
.poll(async () =>
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
)
.toBe(true)
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
await closeDialog(page, dialog)
await ensurePopoverOpen()
const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
await expect(serverRow).toBeVisible()
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
})
@@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
@@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible()
await second.scrollIntoViewIfNeeded()
await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
@@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible()
await third.scrollIntoViewIfNeeded()
await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
@@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))

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