Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e35ee83528 | ||
|
|
94ba546ff4 | ||
|
|
4eb60954f8 | ||
|
|
e8c67888a7 |
No files matched your search
@@ -0,0 +1,37 @@
|
||||
// BEGIN: baseline — first-line change
|
||||
type ReviewState = "pending" | "ready" | "failed"
|
||||
type ReviewProps = { title: string; count: number; state: ReviewState }
|
||||
|
||||
export const heading = "Review the old checkout experience"
|
||||
export const count = 8
|
||||
export const obsolete = "delete this isolated row"
|
||||
export const anchor = "unchanged between deletion and addition"
|
||||
|
||||
// Contiguous span versus several separated small spans.
|
||||
export const contiguous = "Keep the original checkout message readable"
|
||||
export const scattered = "alpha: old; beta: cold; gamma: slow"
|
||||
export const oneCharacter = "item-7"
|
||||
export const atStart = "before middle remains ending remains"
|
||||
export const atMiddle = "start remains before ending remains"
|
||||
export const atEnd = "start remains middle remains before"
|
||||
export const punctuation = { label: "Ready", enabled: true }
|
||||
export const spaces = "one two three"
|
||||
export const indent = "indent-only change"
|
||||
export const trailing = "trailing-space-only change"
|
||||
|
||||
// Syntax competition: comment, keyword, type, boolean, string, number.
|
||||
export function ReviewCard(props: ReviewProps) {
|
||||
const status: ReviewState = "pending"
|
||||
const enabled = false
|
||||
const retries = 3
|
||||
// Keep this quiet explanation readable on a tinted row.
|
||||
return <section aria-label="Old review" data-state={status}>
|
||||
<h2>{props.title}</h2>
|
||||
<span className="text-muted">{props.count} old items</span>
|
||||
<button disabled={!enabled}>Continue checkout</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
export const longLine = "Start unchanged | The old checkout flow waits for manual confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The old final instruction asks the reviewer to close the window | End unchanged"
|
||||
|
||||
// END: baseline — last-line change
|
||||
@@ -0,0 +1,66 @@
|
||||
// BEGIN baseline
|
||||
export const first = "old"
|
||||
// quiet context A01
|
||||
// quiet context A02
|
||||
// quiet context A03
|
||||
// quiet context A04
|
||||
// quiet context A05
|
||||
// quiet context A06
|
||||
// quiet context A07
|
||||
// quiet context A08
|
||||
// quiet context A09
|
||||
// quiet context A10
|
||||
// quiet context A11
|
||||
// quiet context A12
|
||||
// quiet context A13
|
||||
// quiet context A14
|
||||
// quiet context A15
|
||||
// quiet context A16
|
||||
// quiet context A17
|
||||
// quiet context A18
|
||||
// quiet context A19
|
||||
// quiet context A20
|
||||
export const second = "old"
|
||||
// quiet context B01
|
||||
// quiet context B02
|
||||
// quiet context B03
|
||||
// quiet context B04
|
||||
// quiet context B05
|
||||
// quiet context B06
|
||||
// quiet context B07
|
||||
// quiet context B08
|
||||
// quiet context B09
|
||||
// quiet context B10
|
||||
// quiet context B11
|
||||
// quiet context B12
|
||||
// quiet context B13
|
||||
// quiet context B14
|
||||
// quiet context B15
|
||||
// quiet context B16
|
||||
// quiet context B17
|
||||
// quiet context B18
|
||||
// quiet context B19
|
||||
// quiet context B20
|
||||
export const third = "old"
|
||||
// quiet context C01
|
||||
// quiet context C02
|
||||
// quiet context C03
|
||||
// quiet context C04
|
||||
// quiet context C05
|
||||
// quiet context C06
|
||||
// quiet context C07
|
||||
// quiet context C08
|
||||
// quiet context C09
|
||||
// quiet context C10
|
||||
// quiet context C11
|
||||
// quiet context C12
|
||||
// quiet context C13
|
||||
// quiet context C14
|
||||
// quiet context C15
|
||||
// quiet context C16
|
||||
// quiet context C17
|
||||
// quiet context C18
|
||||
// quiet context C19
|
||||
// quiet context C20
|
||||
export const fourth = "old"
|
||||
// END baseline
|
||||
@@ -0,0 +1,21 @@
|
||||
# Before: plain-text review
|
||||
|
||||
Unchanged prose should remain quiet, not compete with changed rows.
|
||||
Replace one contiguous phrase: the original checkout experience stays here.
|
||||
Several little edits: red apple, cold tea, slow train.
|
||||
One character: ticket 7.
|
||||
before — the middle and the end remain unchanged.
|
||||
The start remains — before — the end remains.
|
||||
The start and the middle remain — before
|
||||
Punctuation only: hello, world!
|
||||
Whitespace only: one two three
|
||||
Trailing whitespace only.
|
||||
Delete this sentence without a replacement.
|
||||
|
||||
This unchanged sentence separates a deletion from an addition.
|
||||
|
||||
Long prose: The original checkout flow asks the reader to review the old confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final old phrase appears near the far right edge.
|
||||
|
||||
**Syntax-like Markdown** competes with `inline code`, [links](https://example.com/old), and _emphasis_.
|
||||
|
||||
Final sentence before.
|
||||
@@ -0,0 +1,63 @@
|
||||
// Large file: unchanged prefix and suffix surround a dense block.
|
||||
export const records = [
|
||||
{ id: "row-001", state: "pending", count: 1 },
|
||||
{ id: "row-002", state: "pending", count: 2 },
|
||||
{ id: "row-003", state: "pending", count: 3 },
|
||||
{ id: "row-004", state: "pending", count: 4 },
|
||||
{ id: "row-005", state: "pending", count: 5 },
|
||||
{ id: "row-006", state: "pending", count: 6 },
|
||||
{ id: "row-007", state: "pending", count: 7 },
|
||||
{ id: "row-008", state: "pending", count: 8 },
|
||||
{ id: "row-009", state: "pending", count: 9 },
|
||||
{ id: "row-010", state: "pending", count: 10 },
|
||||
{ id: "row-011", state: "pending", count: 11 },
|
||||
{ id: "row-012", state: "pending", count: 12 },
|
||||
{ id: "row-013", state: "pending", count: 13 },
|
||||
{ id: "row-014", state: "pending", count: 14 },
|
||||
{ id: "row-015", state: "pending", count: 15 },
|
||||
{ id: "row-016", state: "pending", count: 16 },
|
||||
{ id: "row-017", state: "pending", count: 17 },
|
||||
{ id: "row-018", state: "pending", count: 18 },
|
||||
{ id: "row-019", state: "pending", count: 19 },
|
||||
{ id: "row-020", state: "pending", count: 20 },
|
||||
{ id: "row-021", state: "pending", count: 21 },
|
||||
{ id: "row-022", state: "pending", count: 22 },
|
||||
{ id: "row-023", state: "pending", count: 23 },
|
||||
{ id: "row-024", state: "pending", count: 24 },
|
||||
{ id: "row-025", state: "pending", count: 25 },
|
||||
{ id: "row-026", state: "pending", count: 26 },
|
||||
{ id: "row-027", state: "pending", count: 27 },
|
||||
{ id: "row-028", state: "pending", count: 28 },
|
||||
{ id: "row-029", state: "pending", count: 29 },
|
||||
{ id: "row-030", state: "pending", count: 30 },
|
||||
{ id: "row-031", state: "pending", count: 31 },
|
||||
{ id: "row-032", state: "pending", count: 32 },
|
||||
{ id: "row-033", state: "pending", count: 33 },
|
||||
{ id: "row-034", state: "pending", count: 34 },
|
||||
{ id: "row-035", state: "pending", count: 35 },
|
||||
{ id: "row-036", state: "pending", count: 36 },
|
||||
{ id: "row-037", state: "pending", count: 37 },
|
||||
{ id: "row-038", state: "pending", count: 38 },
|
||||
{ id: "row-039", state: "pending", count: 39 },
|
||||
{ id: "row-040", state: "pending", count: 40 },
|
||||
{ id: "row-041", state: "pending", count: 41 },
|
||||
{ id: "row-042", state: "pending", count: 42 },
|
||||
{ id: "row-043", state: "pending", count: 43 },
|
||||
{ id: "row-044", state: "pending", count: 44 },
|
||||
{ id: "row-045", state: "pending", count: 45 },
|
||||
{ id: "row-046", state: "pending", count: 46 },
|
||||
{ id: "row-047", state: "pending", count: 47 },
|
||||
{ id: "row-048", state: "pending", count: 48 },
|
||||
{ id: "row-049", state: "pending", count: 49 },
|
||||
{ id: "row-050", state: "pending", count: 50 },
|
||||
{ id: "row-051", state: "pending", count: 51 },
|
||||
{ id: "row-052", state: "pending", count: 52 },
|
||||
{ id: "row-053", state: "pending", count: 53 },
|
||||
{ id: "row-054", state: "pending", count: 54 },
|
||||
{ id: "row-055", state: "pending", count: 55 },
|
||||
{ id: "row-056", state: "pending", count: 56 },
|
||||
{ id: "row-057", state: "pending", count: 57 },
|
||||
{ id: "row-058", state: "pending", count: 58 },
|
||||
{ id: "row-059", state: "pending", count: 59 },
|
||||
{ id: "row-060", state: "pending", count: 60 },
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
This entire tracked file will be deleted after the baseline commit.
|
||||
Only deletion tint should be present.
|
||||
No syntax colors should compete with this text.
|
||||
Last deleted row.
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Baseline stylesheet: colors, units, selectors, at-rules. */
|
||||
:root {
|
||||
--brand: #3b5cf6;
|
||||
--radius: 6px;
|
||||
--gap: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius);
|
||||
background: #ffffff;
|
||||
color: #161616;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.card[data-state="error"] {
|
||||
color: #b82d35;
|
||||
border-color: #f2bbb7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card {
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "diff-color-fixture",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"settings": {
|
||||
"theme": "light",
|
||||
"fontSize": 13,
|
||||
"lineHeight": 20,
|
||||
"wrap": false,
|
||||
"tabs": ["review", "files", "terminal"]
|
||||
},
|
||||
"features": {
|
||||
"inlineHighlights": true,
|
||||
"foldUnchanged": true,
|
||||
"splitView": false
|
||||
},
|
||||
"limits": {
|
||||
"maxFiles": 100,
|
||||
"maxLineLength": 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Baseline Python service: decorators, f-strings, indentation, comments."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Review:
|
||||
id: str
|
||||
title: str
|
||||
approved: bool = False
|
||||
|
||||
|
||||
class ReviewService:
|
||||
def __init__(self, timeout: float = 2.0) -> None:
|
||||
self.timeout = timeout
|
||||
self.cache: dict[str, Review] = {}
|
||||
|
||||
async def load(self, review_id: str) -> Review | None:
|
||||
# Return cached reviews before touching the network.
|
||||
if review_id in self.cache:
|
||||
return self.cache[review_id]
|
||||
await asyncio.sleep(self.timeout)
|
||||
return None
|
||||
|
||||
def summary(self, review: Review) -> str:
|
||||
status = "approved" if review.approved else "pending"
|
||||
return f"Review {review.id}: {review.title} ({status})"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
service = ReviewService()
|
||||
review = Review(id="r-1", title="Checkout")
|
||||
print(service.summary(review))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
// Moved and reordered blocks: the same code appears as deletion here and addition elsewhere.
|
||||
export function first() {
|
||||
return "first"
|
||||
}
|
||||
|
||||
export function second() {
|
||||
const values = [1, 2, 3]
|
||||
return values.map((value) => value * 2)
|
||||
}
|
||||
|
||||
export function third() {
|
||||
return "third"
|
||||
}
|
||||
|
||||
export function fourth() {
|
||||
return "fourth"
|
||||
}
|
||||
|
||||
export const order = ["first", "second", "third", "fourth"]
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baseline shell script: variables, quoting, conditionals.
|
||||
set -euo pipefail
|
||||
|
||||
ENVIRONMENT="${1:-staging}"
|
||||
REGION="us-east-1"
|
||||
RETRIES=3
|
||||
|
||||
log() {
|
||||
echo "[deploy] $*"
|
||||
}
|
||||
|
||||
if [[ "$ENVIRONMENT" == "production" ]]; then
|
||||
log "Deploying to production in $REGION"
|
||||
else
|
||||
log "Deploying to $ENVIRONMENT"
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 "$RETRIES"); do
|
||||
log "Attempt $attempt of $RETRIES"
|
||||
done
|
||||
@@ -0,0 +1,19 @@
|
||||
name: review
|
||||
on:
|
||||
push:
|
||||
branches: [v2]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install
|
||||
run: bun install
|
||||
- name: Check
|
||||
run: bun run check
|
||||
env:
|
||||
CI: true
|
||||
LOG_LEVEL: info
|
||||
@@ -0,0 +1,12 @@
|
||||
# Unicode and mixed scripts
|
||||
|
||||
Emoji: ✅ ready, ⚠️ warning, ❌ failed.
|
||||
CJK: 代码审查 完成。
|
||||
Japanese: レビューを開始します。
|
||||
Korean: 변경 사항을 검토합니다.
|
||||
Arabic (RTL): مراجعة التغييرات
|
||||
Hebrew (RTL): סקירת שינויים
|
||||
Accents: café, naïve, résumé.
|
||||
Math: a ≤ b, x ≠ y, ∑ values.
|
||||
Box: ┌─┐ │ │ └─┘
|
||||
Combining: e\u0301 vs é
|
||||
@@ -0,0 +1,18 @@
|
||||
package fixture
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Tab-indented Go: tab-width rendering and tab-only changes.
|
||||
type Review struct {
|
||||
ID string
|
||||
Title string
|
||||
Approved bool
|
||||
}
|
||||
|
||||
func (r Review) Summary() string {
|
||||
status := "pending"
|
||||
if r.Approved {
|
||||
status = "approved"
|
||||
}
|
||||
return fmt.Sprintf("%s: %s (%s)", r.ID, r.Title, status)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
This tracked file keeps existing but loses all content.
|
||||
Every row should render as a deletion.
|
||||
The file itself is not deleted.
|
||||
@@ -0,0 +1,2 @@
|
||||
First line
|
||||
Last line without trailing newline
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Review</title>
|
||||
</head>
|
||||
<body class="light">
|
||||
<main id="app" data-state="idle">
|
||||
<h1>Old heading</h1>
|
||||
<button type="button" disabled>Continue</button>
|
||||
<!-- baseline comment -->
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
SELECT id, title, status
|
||||
FROM reviews
|
||||
WHERE status = 'pending'
|
||||
AND created_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50;
|
||||
@@ -0,0 +1,15 @@
|
||||
# Temporary diff color review
|
||||
|
||||
Local-only visual fixture. Not imported by product code. Do not format these files.
|
||||
|
||||
After the baseline commit, leave all example edits uncommitted.
|
||||
|
||||
- `01-inline.tsx`: adjacent rows, inline spans, one character, syntax, whitespace, long line.
|
||||
- `02-folded.ts`: isolated hunks and multiple collapsed unchanged sections.
|
||||
- `03-prose.md`: plain prose, punctuation, spaces, file boundaries, long prose.
|
||||
- `04-many-rows.ts`: larger dense changed region.
|
||||
- `05-deleted.txt` / `06-added.txt`: whole-file deletion / addition.
|
||||
|
||||
Open this worktree in OpenCode, open Review, select Uncommitted changes, and choose a file.
|
||||
Compare the default OpenCode theme in Light and Dark with Unified and Split layouts.
|
||||
Use a 1440 × 1000 viewport for comparisons and 800 × 1000 for the narrow case.
|
||||
@@ -0,0 +1,147 @@
|
||||
# File diff color tokens and CSS variables
|
||||
|
||||
Inventory for this worktree's V2 web/desktop file viewer. Source inspection only; no colors or behavior changed. Includes diff-specific colors, syntax colors, and the selection/search/comment colors used inside the viewer—not every unrelated global app variable. Unified and split use the same color system.
|
||||
|
||||
## Main controls and their wiring
|
||||
|
||||
| Visual role | Renderer variable | OpenCode source |
|
||||
|---|---|---|
|
||||
| Neutral file background | `--diffs-bg` | `--opencode-diffs-bg`, falling back to `--color-background-stronger` (alias of `--background-stronger`) |
|
||||
| Default text | `--fg` / `--diffs-fg` | Registered theme foreground: `--text-base`; syntax spans override it |
|
||||
| Addition seed / gutter bar | `--diffs-addition-color` → `--diffs-addition-base` | `--syntax-diff-add` |
|
||||
| Deletion seed / gutter bar | `--diffs-deletion-color` → `--diffs-deletion-base` | `--syntax-diff-delete` |
|
||||
| Modified seed | `--diffs-modified-color` → `--diffs-modified-base` | `--syntax-diff-unknown` |
|
||||
| Added row | `--diffs-bg-addition`, `--diffs-bg-addition-override` | Mix of neutral background and addition seed |
|
||||
| Deleted row | `--diffs-bg-deletion`, `--diffs-bg-deletion-override` | Mix of neutral background and deletion seed |
|
||||
| Added inline span | `--diffs-bg-addition-emphasis`, `--diffs-bg-addition-emphasis-override` | Alpha of addition seed |
|
||||
| Deleted inline span | `--diffs-bg-deletion-emphasis`, `--diffs-bg-deletion-emphasis-override` | Alpha of deletion seed; softened on selected deletion rows |
|
||||
| Folded “unmodified lines” row | `--diffs-bg-separator`, `--diffs-bg-separator-override` | Mix of neutral background and `--diffs-mixer` |
|
||||
| Ordinary line numbers / fold text | `--diffs-fg-number`, `--diffs-fg-number-override` | Foreground/background mix |
|
||||
| Selection background | `--diffs-selection-base` → `--diffs-bg-selection` | `--v2-background-bg-accent` |
|
||||
| Selected line-number text | `--diffs-selection-number-fg` | `--v2-text-text-accent` |
|
||||
| Comment annotation background | `--diffs-comment-bg` | Alpha of `--v2-background-bg-accent` |
|
||||
| Search match / current match | CSS highlight backgrounds | Alpha of `--surface-warning-base` / `--surface-warning-strong` |
|
||||
|
||||
**Important:** `--surface-diff-add-*`, `--surface-diff-delete-*`, and `--surface-diff-hidden-*` exist in the global theme, but are not the direct row/inline/fold controls in the current Pierre rendering path. The older/custom separator CSS in `components/file.css` does use `--surface-diff-hidden-base` and `--surface-diff-hidden-strong`.
|
||||
|
||||
## OpenCode diff-specific theme tokens — complete families
|
||||
|
||||
Each name below is a CSS custom property. Theme JSON override keys omit the leading `--`. Tailwind's `--color-…` aliases are defined in `packages/ui/src/styles/tailwind/colors.css` (for example `--color-surface-diff-add-base` → `--surface-diff-add-base`); these are aliases, not separate color decisions.
|
||||
|
||||
### Surfaces
|
||||
|
||||
```text
|
||||
--surface-diff-unchanged-base
|
||||
--surface-diff-skip-base
|
||||
--surface-diff-hidden-base
|
||||
--surface-diff-hidden-weak
|
||||
--surface-diff-hidden-weaker
|
||||
--surface-diff-hidden-strong
|
||||
--surface-diff-hidden-stronger
|
||||
--surface-diff-add-base
|
||||
--surface-diff-add-weak
|
||||
--surface-diff-add-weaker
|
||||
--surface-diff-add-strong
|
||||
--surface-diff-add-stronger
|
||||
--surface-diff-delete-base
|
||||
--surface-diff-delete-weak
|
||||
--surface-diff-delete-weaker
|
||||
--surface-diff-delete-strong
|
||||
--surface-diff-delete-stronger
|
||||
```
|
||||
|
||||
### Text, icons, and highlighter diff seeds
|
||||
|
||||
```text
|
||||
--text-diff-add-base
|
||||
--text-diff-add-strong
|
||||
--text-diff-delete-base
|
||||
--text-diff-delete-strong
|
||||
--icon-diff-add-base
|
||||
--icon-diff-add-hover
|
||||
--icon-diff-add-active
|
||||
--icon-diff-delete-base
|
||||
--icon-diff-delete-hover
|
||||
--icon-diff-modified-base
|
||||
--syntax-diff-add
|
||||
--syntax-diff-delete
|
||||
--syntax-diff-unknown
|
||||
```
|
||||
|
||||
The built-in OpenCode theme maps `--syntax-diff-add` and `--text-diff-add-base` to `--v2-state-fg-success`, and their delete counterparts to `--v2-state-fg-danger`. Optional palette seeds `diffAdd` and `diffDelete` also exist for theme resolution; they are theme inputs, not CSS variable names, and built-in overrides can supersede generated results.
|
||||
|
||||
## Syntax foreground tokens
|
||||
|
||||
```text
|
||||
--syntax-comment
|
||||
--syntax-regexp
|
||||
--syntax-string
|
||||
--syntax-keyword
|
||||
--syntax-primitive
|
||||
--syntax-operator
|
||||
--syntax-variable
|
||||
--syntax-property
|
||||
--syntax-type
|
||||
--syntax-constant
|
||||
--syntax-punctuation
|
||||
--syntax-object
|
||||
--syntax-success
|
||||
--syntax-warning
|
||||
--syntax-critical
|
||||
--syntax-info
|
||||
--syntax-unknown
|
||||
```
|
||||
|
||||
`--syntax-unknown` is referenced by the registered highlighter, but no definition was found in the current UI theme sources: treat it as an unresolved reference, not an existing resolved theme token. `--syntax-success` is available globally, although not directly used by that registered theme's current token-color rules.
|
||||
|
||||
Built-in syntax overrides additionally reference `--v2-text-text-muted`, `--v2-pink-800`, `--v2-green-800`, `--v2-orange-800`, `--v2-purple-800`, and `--v2-red-800` (with separate dark/light resolutions). Other syntax tokens use the ordinary text tokens or theme-resolved colors.
|
||||
|
||||
## Pierre renderer color variables — complete relevant inventory
|
||||
|
||||
These come from the installed `@pierre/diffs` **1.5.1** stylesheet plus OpenCode's injected CSS. `*-override` variables are hooks; other variables include derived outputs and internal implementation details, not independent OpenCode theme tokens. Some optional hooks are unset by default.
|
||||
|
||||
| Group | Variables |
|
||||
|---|---|
|
||||
| Base | `--diffs-bg`, `--diffs-fg`, `--diffs-mixer`, `--opencode-diffs-bg`, `--bg`, `--fg` |
|
||||
| Theme foreground/background variants | `--diffs-light`, `--diffs-dark`, `--diffs-light-bg`, `--diffs-dark-bg` |
|
||||
| Neutral backgrounds | `--diffs-bg-buffer`, `--diffs-bg-buffer-override`, `--diffs-bg-context`, `--diffs-bg-context-override`, `--diffs-bg-context-gutter`, `--diffs-bg-context-gutter-override`, `--diffs-bg-separator`, `--diffs-bg-separator-override` |
|
||||
| Number / conflict-marker foreground | `--diffs-fg-number`, `--diffs-fg-number-override`, `--diffs-fg-number-addition-override`, `--diffs-fg-number-deletion-override`, `--diffs-fg-conflict-marker`, `--diffs-fg-conflict-marker-override` |
|
||||
| Addition seed | `--diffs-addition-base`, `--diffs-addition-color`, `--diffs-addition-color-override`, `--diffs-light-addition-color`, `--diffs-dark-addition-color` |
|
||||
| Deletion seed | `--diffs-deletion-base`, `--diffs-deletion-color`, `--diffs-deletion-color-override`, `--diffs-light-deletion-color`, `--diffs-dark-deletion-color` |
|
||||
| Modified seed | `--diffs-modified-base`, `--diffs-modified-color`, `--diffs-modified-color-override`, `--diffs-light-modified-color`, `--diffs-dark-modified-color` |
|
||||
| Renderer fallback colors | `--diffs-added-light`, `--diffs-added-dark`, `--diffs-deleted-light`, `--diffs-deleted-dark`, `--diffs-modified-light`, `--diffs-modified-dark`, `--diffs-warning-light`, `--diffs-warning-dark` |
|
||||
| Added row/gutter/inline | `--diffs-bg-addition`, `--diffs-bg-addition-override`, `--diffs-bg-addition-number-override`, `--diffs-bg-addition-emphasis`, `--diffs-bg-addition-emphasis-override` |
|
||||
| Deleted row/gutter/inline | `--diffs-bg-deletion`, `--diffs-bg-deletion-override`, `--diffs-bg-deletion-number-override`, `--diffs-bg-deletion-emphasis`, `--diffs-bg-deletion-emphasis-override` |
|
||||
| Selection | `--diffs-selection-base`, `--diffs-selection-number-fg`, `--diffs-bg-selection`, `--diffs-bg-selection-override`, `--diffs-bg-selection-number`, `--diffs-bg-selection-number-override`, `--diffs-bg-selection-text` |
|
||||
| Hover | `--diffs-bg-hover-override`, `--diffs-hover-mix-target` |
|
||||
| Comments / decoration | `--diffs-comment-bg`, `--diffs-annotation-bg`, `--diffs-decoration-bg`, `--diffs-decoration-bar-color` |
|
||||
| Internal background pipeline | `--diffs-computed-decoration-bg`, `--diffs-computed-diff-line-bg`, `--diffs-computed-selected-line-bg`, `--diffs-computed-editor-active-line-bg`, `--diffs-computed-hovered-line-bg`, `--diffs-line-bg` |
|
||||
| Internal mix targets | `--diffs-diff-line-mix-target`, `--diffs-selection-mix-target`, `--diffs-selection-emphasis-mix-target` |
|
||||
| Per-token light/dark colors | `--diffs-token-light`, `--diffs-token-dark`, `--diffs-token-light-bg`, `--diffs-token-dark-bg` |
|
||||
| Conflict backgrounds (library support; not exercised by the fixture) | `--conflict-bg-current-header-override`, `--conflict-bg-current-number-override`, `--conflict-bg-current-override`, `--conflict-bg-incoming-header-override`, `--conflict-bg-incoming-number-override`, `--conflict-bg-incoming-override` |
|
||||
|
||||
Related mix controls are **percentages, not colors**: `--mix-light`, `--mix-dark`, `--mix-deco-light`, `--mix-deco-dark`, `--mix-selection-light`, `--mix-selection-dark`, and `--diffs-editor-active-line-source-mix`.
|
||||
|
||||
## Other color tokens used inside the file viewer
|
||||
|
||||
- Text: `--text-base`, `--text-weak`, `--text-strong`.
|
||||
- Background fallback: `--background-stronger`, `--color-background-stronger`; comment actions also use `--background-base`.
|
||||
- Selection: `--v2-background-bg-accent`, `--v2-text-text-accent`.
|
||||
- Search: `--surface-warning-base`, `--surface-warning-strong`.
|
||||
- Custom fold UI: `--surface-diff-hidden-base`, `--surface-diff-hidden-strong`, `--icon-strong-base`; `--text-mix-blend-mode` affects compositing but is not a color.
|
||||
- Comment controls: `--icon-interactive-base`, `--white`, `--surface-raised-stronger-non-alpha`, `--surface-base`, `--surface-raised-base-hover`, `--border-base`, `--text-weak`, `--text-strong`, `--background-base`.
|
||||
- Comment shadows (composite shadow tokens, not single colors): `--shadow-xs`, `--shadow-xs-border-focus`, `--shadow-xxs-border`, `--shadow-xs-border-select`.
|
||||
|
||||
Typography, sizing, gaps, gutter widths, font weight/style, and text-decoration variables are deliberately excluded from this color inventory.
|
||||
|
||||
## Source locations
|
||||
|
||||
- `packages/session-ui/src/pierre/index.ts`: viewer color overrides, selection, search, comment tint.
|
||||
- `packages/ui/src/context/marked-theme.tsx`: registered foreground/background, diff seeds, syntax mappings.
|
||||
- `packages/ui/src/theme/resolve.ts`: global diff/syntax token generation.
|
||||
- `packages/ui/src/theme/themes/oc-2.json`: built-in light/dark mappings.
|
||||
- `packages/ui/src/styles/theme.css` and `styles/tailwind/colors.css`: defaults and aliases.
|
||||
- `packages/session-ui/src/components/file.css`: custom fold-separator styling.
|
||||
- `packages/session-ui/src/components/line-comment-styles.ts`: comment controls.
|
||||
- Installed `@pierre/diffs/dist/style.js`: renderer variables and derived backgrounds.
|
||||
- Installed `@pierre/diffs/dist/utils/getHighlighterThemeStyles.js`: highlighter-to-renderer seed bridge.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Diff color review (temporary)
|
||||
|
||||
Review-only material for the diff color PR. This whole commit is dropped before merge: `diff-color-fixture/`,
|
||||
`diff-color-review-artifacts/`, `packages/session-ui/src/pierre/diff-color-tuning.ts`, and its import in
|
||||
`packages/session-ui/src/pierre/index.ts`.
|
||||
|
||||
## See the example diffs
|
||||
|
||||
The fixture baselines are committed; their edits ship as a patch so they show up as uncommitted Git changes.
|
||||
|
||||
```sh
|
||||
git apply diff-color-review-artifacts/fixture-edits.patch
|
||||
```
|
||||
|
||||
Open a session in this worktree, open **Review → Git changes**, and pick a file in `diff-color-fixture/`.
|
||||
Undo with `git checkout -- diff-color-fixture && git clean -fd diff-color-fixture`.
|
||||
|
||||
## Tune colors yourself
|
||||
|
||||
With the desktop dev app running from this worktree (`bun run dev:desktop`):
|
||||
|
||||
```sh
|
||||
bun run diff-color-review-artifacts/tuner/server.ts
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:4455 (any browser, or the OpenCode browser pane).
|
||||
|
||||
- Every change saves to `diff-color-tuning.ts` (hot-reloaded) and is pushed live into open diffs through the dev
|
||||
app's debug port (9222; override with `TUNER_CDP`).
|
||||
- **Override set**: `Reference` is the committed, read-only set used for this PR (it matches the branch colors, so it
|
||||
previews as no change). Editing it saves a copy; use **New** for a blank set. Your sets stay local and untracked.
|
||||
- “Current” leaves the branch colors untouched. Slots can be an exact color or a v2 token, with opacity.
|
||||
- **Copy CSS** / **Paste CSS** share a set with someone else; Undo/Redo and per-category toggles are in the toolbar.
|
||||
|
||||
`REPORT.md` and the screenshots document the colors before this change; `COLOR-TOKENS.md` lists the diff view's
|
||||
color tokens and Pierre variables.
|
||||
@@ -0,0 +1,69 @@
|
||||
# OpenCode V2 diff color review — temporary fixture
|
||||
|
||||
[File diff color token / CSS variable inventory](COLOR-TOKENS.md)
|
||||
|
||||
## Location and refs
|
||||
|
||||
- Worktree: `/Users/usrnk1/.local/share/opencode/worktree/012780/diff-color-review`
|
||||
- Branch: `diff-color-review` (local only).
|
||||
- Fetched `origin/v2`: `7e42a897bc6dbbba580392d779e47137038bcafb`.
|
||||
- Local baseline / HEAD: `5e14ede13c260b3b1bd2e16c5bb25722d12f366b`.
|
||||
- Fixture: `diff-color-fixture/01-inline.tsx`, `02-folded.ts`, `03-prose.md`, `04-many-rows.ts`, `05-deleted.txt` (deleted), `06-added.txt` (untracked addition).
|
||||
- `diff-color-fixture/README.md` is tracked and unchanged.
|
||||
- Only fixtures changed. No renderer, token, behavior, formatter, push, PR, or deployment changes.
|
||||
|
||||
## Actual UI and how to open
|
||||
|
||||
The captures use the real source-backed OpenCode web app from this worktree, connected to the existing V2 background server (2.0.21). No mocked data or copied renderer. The app dev server remains available at `http://127.0.0.1:4444` and was started with `VITE_OPENCODE_SERVER_PORT=49374 bun run dev -- --port 4444` from `packages/app`. Existing app/server processes were not restarted.
|
||||
|
||||
Open:
|
||||
|
||||
`http://127.0.0.1:4444/server/aHR0cDovLzEyNy4wLjAuMTo0OTM3NA/session/ses_f085f0176ffeiNj9364c2CnHqm`
|
||||
|
||||
Alternatively open this session in OpenCode Desktop; its location is now this worktree. Toggle Review, choose **Git changes**, and select a fixture file. This UI calls the working-tree source “Git changes,” not “Uncommitted changes.” Use the file-tree toggle if the list is hidden. Unified/Split controls are in the review toolbar.
|
||||
|
||||
Captures: 1440 × 1000 CSS pixels, default OpenCode (`oc-2`) theme, system light/dark media preference. Chat pane was resized to its minimum and the file tree hidden to give the diff about 964 pixels. Wrapping was enabled by the production viewer. Close-ups below are unchanged crops of those screenshots, not re-rendered mockups.
|
||||
|
||||
## Representative screenshots
|
||||
|
||||
- [Dark inline close-up](dark-inline-closeup.png) / [Light inline close-up](light-inline-closeup.png)
|
||||
- [Dark split inline](dark-split-inline.png) / [Light split inline](light-split-inline.png)
|
||||
- [Dark split folded: all three bars](dark-split-folded.png) / [Light split folded](light-split-folded.png)
|
||||
- [Dark unified folded](dark-unified-folded.png) / [Light unified folded](light-unified-folded.png)
|
||||
- [Dark syntax and wrapped long line](dark-split-syntax-long.png) / [Light syntax and wrapped long line](light-split-syntax-long.png)
|
||||
- [Dark Markdown](dark-split-prose.png) / [Light Markdown](light-split-prose.png)
|
||||
|
||||
Additional unified captures are in this same directory.
|
||||
|
||||
## Case notes
|
||||
|
||||
| Case | Unchanged sections | Red/green scan | Inline emphasis | Text readability |
|
||||
|---|---|---|---|---|
|
||||
| Single additions/deletions/replacements, adjacent rows (`01`, `03`) | Neutral background recedes; unchanged syntax can still draw the eye | Light is clearer; dark relies more on gutter bars and line numbers | Distinct but restrained | TS strings stay green even on red deletion rows, competing with change meaning |
|
||||
| Contiguous versus separated edits (`01` lines 11–12; long line) | Common words inside an inline span do **not** recede | Row direction is still clear from gutter and tint | Current production `word-line` rendering joins separated edits into one broad span; multiple independent highlight islands were not available in this state | Broad wrapped emphasis adds visual weight without improving precision |
|
||||
| One-character, start/middle/end edits (`01` lines 13–16; `03`) | Surrounding text remains readable | Large words are easy to find; one-character changes need deliberate attention | One-character patch is visible but easy to miss at normal scan speed | No observed loss of string readability |
|
||||
| Punctuation and spaces/indent/trailing spaces (`01` lines 17–20; `03`) | Quiet surroundings | Row markers expose that something changed | Small semicolon/space blocks are visible on close inspection; blank blocks give little explanation without visible whitespace glyphs | Readable; punctuation has less salience than colored keywords |
|
||||
| Isolated hunks and collapsed sections (`02`) | Neutral context recedes; three bars visible simultaneously in split | Distinct isolated rows, but dark tints are weak | Small changed-word blocks don't dominate rows | Fold-label text is unusually faint, especially dark; context comments are clearer than the fold labels |
|
||||
| TS/TSX types, keywords, comments and JSX (`01`) | Syntax in unchanged rows remains fairly prominent | Pink keywords/green strings compete with the red/green layer | Emphasis is generally subordinate; the long merged span is the exception | Comments/strings/types readable overall; muted JSX words like “old,” “new,” and button text are weak on tinted/highlighted backgrounds in dark mode |
|
||||
| Markdown and wrapped prose (`03`) | Plain prose is visually quieter than code | Light tints are clearer than dark | Long spans emphasize unchanged middle words as well as changes | Plain prose remains readable but looks muted; bold/heading syntax draws attention more strongly than small edit spans |
|
||||
| Long/wrapped lines (`01`, `03`) | Common middle text is swallowed by merged emphasis | Tinted multi-line blocks are apparent | Broad highlights repeat across wraps and visually dominate small changes | Wrapping works in unified and split; side-by-side requires more vertical scanning |
|
||||
| File beginning/end (`01`, `02`, `03`) | Neutral surrounding rows recede | Changes shown at first/last lines | Same inline behavior as middle edits | Readable |
|
||||
| Dense larger file (`04`: 63 lines, 40 replacements / 80 changed rows) | **Not visually checked** | Not checked | Not checked | Fixture ready for review |
|
||||
| Added/deleted whole files (`05`, `06`) | Viewer lists both with D/A badges | **File contents not visually checked** | Not checked | Fixture ready for review |
|
||||
| Narrow viewport (planned 800 × 1000) | **Not checked** | Not checked | Not checked | Stopped expanding capture scope at user request |
|
||||
|
||||
## Specific problems to carry into a later design pass
|
||||
|
||||
1. Dark row backgrounds have weak separation from neutral context; syntax hue is often more salient than change direction.
|
||||
2. Green string syntax appears on deleted rows too, weakening red/green semantic scanning. Pink keywords similarly attract attention independently of change status.
|
||||
3. Collapsed-section labels recede too far: their subdued text is harder to read than surrounding context comments.
|
||||
4. One-character, punctuation, and whitespace highlights are easy to miss without focused inspection. Their issue is subtlety/size, not overpowering brightness.
|
||||
5. Muted JSX text on dark inline backgrounds is less readable than the surrounding syntax. Broad inline spans across wrapped lines add disproportionate visual mass.
|
||||
|
||||
The merged-span behavior is a renderer observation, not a proposed color fix. No final values selected, no fixes implemented, and no contrast-ratio compliance claim made.
|
||||
|
||||
## GitHub Desktop comparison boundary
|
||||
|
||||
The referenced discussion/image was not included in this session. A precise comparison to that direction is therefore **not verified**. As a provisional hierarchy comparison only: quiet context → recognizable changed row → localized stronger inline emphasis is the useful target. OpenCode already keeps ordinary inline backgrounds subordinate to rows, but dark row separation, syntax competition, faint fold controls, and merged broad spans weaken that hierarchy. This is not a claim that the specific GitHub Desktop reference was inspected.
|
||||
|
||||
Scope was intentionally stopped after the user's request to do less. No full lint/typecheck was run: these are standalone visual examples with deliberate whitespace/formatting changes, not product code.
|
||||
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 289 KiB |
@@ -0,0 +1,601 @@
|
||||
diff --git a/diff-color-fixture/01-inline.tsx b/diff-color-fixture/01-inline.tsx
|
||||
index 1d16049d61..5074a3bc83 100644
|
||||
--- a/diff-color-fixture/01-inline.tsx
|
||||
+++ b/diff-color-fixture/01-inline.tsx
|
||||
@@ -1,37 +1,37 @@
|
||||
-// BEGIN: baseline — first-line change
|
||||
+// BEGIN: edited — first-line change
|
||||
type ReviewState = "pending" | "ready" | "failed"
|
||||
type ReviewProps = { title: string; count: number; state: ReviewState }
|
||||
|
||||
-export const heading = "Review the old checkout experience"
|
||||
-export const count = 8
|
||||
-export const obsolete = "delete this isolated row"
|
||||
+export const heading = "Review the new checkout experience"
|
||||
+export const count = 9
|
||||
export const anchor = "unchanged between deletion and addition"
|
||||
+export const added = "add this isolated row"
|
||||
|
||||
// Contiguous span versus several separated small spans.
|
||||
-export const contiguous = "Keep the original checkout message readable"
|
||||
-export const scattered = "alpha: old; beta: cold; gamma: slow"
|
||||
-export const oneCharacter = "item-7"
|
||||
-export const atStart = "before middle remains ending remains"
|
||||
-export const atMiddle = "start remains before ending remains"
|
||||
-export const atEnd = "start remains middle remains before"
|
||||
-export const punctuation = { label: "Ready", enabled: true }
|
||||
-export const spaces = "one two three"
|
||||
-export const indent = "indent-only change"
|
||||
-export const trailing = "trailing-space-only change"
|
||||
+export const contiguous = "Keep the redesigned payment summary readable"
|
||||
+export const scattered = "alpha: new; beta: warm; gamma: fast"
|
||||
+export const oneCharacter = "item-8"
|
||||
+export const atStart = "after middle remains ending remains"
|
||||
+export const atMiddle = "start remains after ending remains"
|
||||
+export const atEnd = "start remains middle remains after"
|
||||
+export const punctuation = { label: "Ready", enabled: true };
|
||||
+export const spaces = "one two three"
|
||||
+ export const indent = "indent-only change"
|
||||
+export const trailing = "trailing-space-only change"
|
||||
|
||||
// Syntax competition: comment, keyword, type, boolean, string, number.
|
||||
export function ReviewCard(props: ReviewProps) {
|
||||
- const status: ReviewState = "pending"
|
||||
- const enabled = false
|
||||
- const retries = 3
|
||||
- // Keep this quiet explanation readable on a tinted row.
|
||||
- return <section aria-label="Old review" data-state={status}>
|
||||
+ const status: ReviewState | undefined = "ready"
|
||||
+ const enabled = true
|
||||
+ const retries = 4
|
||||
+ // Keep this revised explanation readable on a tinted row.
|
||||
+ return <article aria-label="New review" data-state={status}>
|
||||
<h2>{props.title}</h2>
|
||||
- <span className="text-muted">{props.count} old items</span>
|
||||
- <button disabled={!enabled}>Continue checkout</button>
|
||||
- </section>
|
||||
+ <span className="text-primary">{props.count} new items</span>
|
||||
+ <button disabled={!enabled}>Confirm payment</button>
|
||||
+ </article>
|
||||
}
|
||||
|
||||
-export const longLine = "Start unchanged | The old checkout flow waits for manual confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The old final instruction asks the reviewer to close the window | End unchanged"
|
||||
+export const longLine = "Start unchanged | The new payment flow waits for automatic confirmation before showing the receipt | Middle unchanged with punctuation: brackets [one, two], braces {three}, quotes 'four', slash /five/ | The new final instruction asks the reviewer to keep the window | End unchanged"
|
||||
|
||||
-// END: baseline — last-line change
|
||||
+// END: edited — last-line change
|
||||
diff --git a/diff-color-fixture/02-folded.ts b/diff-color-fixture/02-folded.ts
|
||||
index 07541ab49d..a5b9b354c3 100644
|
||||
--- a/diff-color-fixture/02-folded.ts
|
||||
+++ b/diff-color-fixture/02-folded.ts
|
||||
@@ -1,5 +1,5 @@
|
||||
-// BEGIN baseline
|
||||
-export const first = "old"
|
||||
+// BEGIN edited
|
||||
+export const first = "new"
|
||||
// quiet context A01
|
||||
// quiet context A02
|
||||
// quiet context A03
|
||||
@@ -20,7 +20,7 @@ export const first = "old"
|
||||
// quiet context A18
|
||||
// quiet context A19
|
||||
// quiet context A20
|
||||
-export const second = "old"
|
||||
+export const second = "new"
|
||||
// quiet context B01
|
||||
// quiet context B02
|
||||
// quiet context B03
|
||||
@@ -41,7 +41,7 @@ export const second = "old"
|
||||
// quiet context B18
|
||||
// quiet context B19
|
||||
// quiet context B20
|
||||
-export const third = "old"
|
||||
+export const third = "new"
|
||||
// quiet context C01
|
||||
// quiet context C02
|
||||
// quiet context C03
|
||||
@@ -62,5 +62,5 @@ export const third = "old"
|
||||
// quiet context C18
|
||||
// quiet context C19
|
||||
// quiet context C20
|
||||
-export const fourth = "old"
|
||||
-// END baseline
|
||||
+export const fourth = "new"
|
||||
+// END edited
|
||||
diff --git a/diff-color-fixture/03-prose.md b/diff-color-fixture/03-prose.md
|
||||
index 4d5b31e3b9..7d01146b6a 100644
|
||||
--- a/diff-color-fixture/03-prose.md
|
||||
+++ b/diff-color-fixture/03-prose.md
|
||||
@@ -1,21 +1,21 @@
|
||||
-# Before: plain-text review
|
||||
+# After: plain-text review
|
||||
|
||||
Unchanged prose should remain quiet, not compete with changed rows.
|
||||
-Replace one contiguous phrase: the original checkout experience stays here.
|
||||
-Several little edits: red apple, cold tea, slow train.
|
||||
-One character: ticket 7.
|
||||
-before — the middle and the end remain unchanged.
|
||||
-The start remains — before — the end remains.
|
||||
-The start and the middle remain — before
|
||||
-Punctuation only: hello, world!
|
||||
-Whitespace only: one two three
|
||||
-Trailing whitespace only.
|
||||
-Delete this sentence without a replacement.
|
||||
+Replace one contiguous phrase: the redesigned payment summary stays here.
|
||||
+Several little edits: green apple, warm tea, fast train.
|
||||
+One character: ticket 8.
|
||||
+after — the middle and the end remain unchanged.
|
||||
+The start remains — after — the end remains.
|
||||
+The start and the middle remain — after
|
||||
+Punctuation only: hello; world?
|
||||
+Whitespace only: one two three
|
||||
+Trailing whitespace only.
|
||||
|
||||
This unchanged sentence separates a deletion from an addition.
|
||||
+Add this sentence without a corresponding deletion.
|
||||
|
||||
-Long prose: The original checkout flow asks the reader to review the old confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final old phrase appears near the far right edge.
|
||||
+Long prose: The redesigned payment flow asks the reader to review the new confirmation message before proceeding through the receipt screen, while the unchanged middle of this deliberately long paragraph checks whether horizontal scrolling or line wrapping keeps small inline edits visible at a narrow viewport; the final new phrase appears near the far right edge.
|
||||
|
||||
-**Syntax-like Markdown** competes with `inline code`, [links](https://example.com/old), and _emphasis_.
|
||||
+**Syntax-rich Markdown** competes with `inline code`, [links](https://example.com/new), and _emphasis_.
|
||||
|
||||
-Final sentence before.
|
||||
+Final sentence after.
|
||||
diff --git a/diff-color-fixture/04-many-rows.ts b/diff-color-fixture/04-many-rows.ts
|
||||
index 7a8c6ecd49..b3e544ae5f 100644
|
||||
--- a/diff-color-fixture/04-many-rows.ts
|
||||
+++ b/diff-color-fixture/04-many-rows.ts
|
||||
@@ -10,46 +10,46 @@ export const records = [
|
||||
{ id: "row-008", state: "pending", count: 8 },
|
||||
{ id: "row-009", state: "pending", count: 9 },
|
||||
{ id: "row-010", state: "pending", count: 10 },
|
||||
- { id: "row-011", state: "pending", count: 11 },
|
||||
- { id: "row-012", state: "pending", count: 12 },
|
||||
- { id: "row-013", state: "pending", count: 13 },
|
||||
- { id: "row-014", state: "pending", count: 14 },
|
||||
- { id: "row-015", state: "pending", count: 15 },
|
||||
- { id: "row-016", state: "pending", count: 16 },
|
||||
- { id: "row-017", state: "pending", count: 17 },
|
||||
- { id: "row-018", state: "pending", count: 18 },
|
||||
- { id: "row-019", state: "pending", count: 19 },
|
||||
- { id: "row-020", state: "pending", count: 20 },
|
||||
- { id: "row-021", state: "pending", count: 21 },
|
||||
- { id: "row-022", state: "pending", count: 22 },
|
||||
- { id: "row-023", state: "pending", count: 23 },
|
||||
- { id: "row-024", state: "pending", count: 24 },
|
||||
- { id: "row-025", state: "pending", count: 25 },
|
||||
- { id: "row-026", state: "pending", count: 26 },
|
||||
- { id: "row-027", state: "pending", count: 27 },
|
||||
- { id: "row-028", state: "pending", count: 28 },
|
||||
- { id: "row-029", state: "pending", count: 29 },
|
||||
- { id: "row-030", state: "pending", count: 30 },
|
||||
- { id: "row-031", state: "pending", count: 31 },
|
||||
- { id: "row-032", state: "pending", count: 32 },
|
||||
- { id: "row-033", state: "pending", count: 33 },
|
||||
- { id: "row-034", state: "pending", count: 34 },
|
||||
- { id: "row-035", state: "pending", count: 35 },
|
||||
- { id: "row-036", state: "pending", count: 36 },
|
||||
- { id: "row-037", state: "pending", count: 37 },
|
||||
- { id: "row-038", state: "pending", count: 38 },
|
||||
- { id: "row-039", state: "pending", count: 39 },
|
||||
- { id: "row-040", state: "pending", count: 40 },
|
||||
- { id: "row-041", state: "pending", count: 41 },
|
||||
- { id: "row-042", state: "pending", count: 42 },
|
||||
- { id: "row-043", state: "pending", count: 43 },
|
||||
- { id: "row-044", state: "pending", count: 44 },
|
||||
- { id: "row-045", state: "pending", count: 45 },
|
||||
- { id: "row-046", state: "pending", count: 46 },
|
||||
- { id: "row-047", state: "pending", count: 47 },
|
||||
- { id: "row-048", state: "pending", count: 48 },
|
||||
- { id: "row-049", state: "pending", count: 49 },
|
||||
- { id: "row-050", state: "pending", count: 50 },
|
||||
+ { id: "row-011", state: "ready", count: 111 },
|
||||
+ { id: "row-012", state: "ready", count: 112 },
|
||||
+ { id: "row-013", state: "ready", count: 113 },
|
||||
+ { id: "row-014", state: "ready", count: 114 },
|
||||
+ { id: "row-015", state: "ready", count: 115 },
|
||||
+ { id: "row-016", state: "ready", count: 116 },
|
||||
+ { id: "row-017", state: "ready", count: 117 },
|
||||
+ { id: "row-018", state: "ready", count: 118 },
|
||||
+ { id: "row-019", state: "ready", count: 119 },
|
||||
+ { id: "row-020", state: "ready", count: 120 },
|
||||
+ { id: "row-021", state: "ready", count: 121 },
|
||||
+ { id: "row-022", state: "ready", count: 122 },
|
||||
+ { id: "row-023", state: "ready", count: 123 },
|
||||
+ { id: "row-024", state: "ready", count: 124 },
|
||||
+ { id: "row-025", state: "ready", count: 125 },
|
||||
+ { id: "row-026", state: "ready", count: 126 },
|
||||
+ { id: "row-027", state: "ready", count: 127 },
|
||||
+ { id: "row-028", state: "ready", count: 128 },
|
||||
+ { id: "row-029", state: "ready", count: 129 },
|
||||
+ { id: "row-030", state: "ready", count: 130 },
|
||||
+ { id: "row-031", state: "ready", count: 131 },
|
||||
+ { id: "row-032", state: "ready", count: 132 },
|
||||
+ { id: "row-033", state: "ready", count: 133 },
|
||||
+ { id: "row-034", state: "ready", count: 134 },
|
||||
+ { id: "row-035", state: "ready", count: 135 },
|
||||
+ { id: "row-036", state: "ready", count: 136 },
|
||||
+ { id: "row-037", state: "ready", count: 137 },
|
||||
+ { id: "row-038", state: "ready", count: 138 },
|
||||
+ { id: "row-039", state: "ready", count: 139 },
|
||||
+ { id: "row-040", state: "ready", count: 140 },
|
||||
+ { id: "row-041", state: "ready", count: 141 },
|
||||
+ { id: "row-042", state: "ready", count: 142 },
|
||||
+ { id: "row-043", state: "ready", count: 143 },
|
||||
+ { id: "row-044", state: "ready", count: 144 },
|
||||
+ { id: "row-045", state: "ready", count: 145 },
|
||||
+ { id: "row-046", state: "ready", count: 146 },
|
||||
+ { id: "row-047", state: "ready", count: 147 },
|
||||
+ { id: "row-048", state: "ready", count: 148 },
|
||||
+ { id: "row-049", state: "ready", count: 149 },
|
||||
+ { id: "row-050", state: "ready", count: 150 },
|
||||
{ id: "row-051", state: "pending", count: 51 },
|
||||
{ id: "row-052", state: "pending", count: 52 },
|
||||
{ id: "row-053", state: "pending", count: 53 },
|
||||
diff --git a/diff-color-fixture/05-deleted.txt b/diff-color-fixture/05-deleted.txt
|
||||
deleted file mode 100644
|
||||
index e80ad6b276..0000000000
|
||||
--- a/diff-color-fixture/05-deleted.txt
|
||||
+++ /dev/null
|
||||
@@ -1,4 +0,0 @@
|
||||
-This entire tracked file will be deleted after the baseline commit.
|
||||
-Only deletion tint should be present.
|
||||
-No syntax colors should compete with this text.
|
||||
-Last deleted row.
|
||||
diff --git a/diff-color-fixture/06-added.txt b/diff-color-fixture/06-added.txt
|
||||
new file mode 100644
|
||||
index 0000000000..f27ed60c9b
|
||||
--- /dev/null
|
||||
+++ b/diff-color-fixture/06-added.txt
|
||||
@@ -0,0 +1,5 @@
|
||||
+This entire file is newly added, not a replacement for the deleted example.
|
||||
+Only addition tint should be present.
|
||||
+Plain text: keep the words readable against the changed-row background.
|
||||
+A deliberately long added line checks the horizontal extent of the green row while the reviewer compares the quiet surrounding application surface against the color used for new content in both dark and light modes.
|
||||
+Last added row.
|
||||
diff --git a/diff-color-fixture/07-styles.css b/diff-color-fixture/07-styles.css
|
||||
index a757ef7085..4f4937480e 100644
|
||||
--- a/diff-color-fixture/07-styles.css
|
||||
+++ b/diff-color-fixture/07-styles.css
|
||||
@@ -1,32 +1,35 @@
|
||||
-/* Baseline stylesheet: colors, units, selectors, at-rules. */
|
||||
+/* Edited stylesheet: colors, units, selectors, at-rules. */
|
||||
:root {
|
||||
- --brand: #3b5cf6;
|
||||
- --radius: 6px;
|
||||
+ --brand: #0b34f4;
|
||||
+ --radius: 8px;
|
||||
--gap: 8px;
|
||||
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card {
|
||||
- display: flex;
|
||||
+ display: grid;
|
||||
gap: var(--gap);
|
||||
- padding: 12px 16px;
|
||||
- border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
+ padding: 12px 20px;
|
||||
+ border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: var(--radius);
|
||||
background: #ffffff;
|
||||
color: #161616;
|
||||
+ box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
-.card:hover {
|
||||
- background: #fafafa;
|
||||
+.card:hover,
|
||||
+.card:focus-visible {
|
||||
+ background: #f5f5f5;
|
||||
}
|
||||
|
||||
.card[data-state="error"] {
|
||||
- color: #b82d35;
|
||||
+ color: #d92e3c;
|
||||
border-color: #f2bbb7;
|
||||
}
|
||||
|
||||
-@media (max-width: 768px) {
|
||||
+@media (max-width: 640px) {
|
||||
.card {
|
||||
- flex-direction: column;
|
||||
+ grid-template-columns: 1fr;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
@@ -34,8 +37,10 @@
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
+ transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
+ transform: none;
|
||||
}
|
||||
}
|
||||
diff --git a/diff-color-fixture/08-config.json b/diff-color-fixture/08-config.json
|
||||
index 09631d931c..8b6f1485fd 100644
|
||||
--- a/diff-color-fixture/08-config.json
|
||||
+++ b/diff-color-fixture/08-config.json
|
||||
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"name": "diff-color-fixture",
|
||||
- "version": "1.0.0",
|
||||
+ "version": "1.1.0",
|
||||
"private": true,
|
||||
"settings": {
|
||||
- "theme": "light",
|
||||
+ "theme": "dark",
|
||||
"fontSize": 13,
|
||||
- "lineHeight": 20,
|
||||
- "wrap": false,
|
||||
- "tabs": ["review", "files", "terminal"]
|
||||
+ "lineHeight": 24,
|
||||
+ "wrap": true,
|
||||
+ "tabs": ["review", "files", "terminal", "browser"]
|
||||
},
|
||||
"features": {
|
||||
"inlineHighlights": true,
|
||||
"foldUnchanged": true,
|
||||
- "splitView": false
|
||||
+ "splitView": true,
|
||||
+ "wordDiff": "word-line"
|
||||
},
|
||||
"limits": {
|
||||
- "maxFiles": 100,
|
||||
+ "maxFiles": 250,
|
||||
"maxLineLength": 1000
|
||||
}
|
||||
}
|
||||
diff --git a/diff-color-fixture/09-service.py b/diff-color-fixture/09-service.py
|
||||
index 64fe32db80..5da0737cf5 100644
|
||||
--- a/diff-color-fixture/09-service.py
|
||||
+++ b/diff-color-fixture/09-service.py
|
||||
@@ -1,36 +1,44 @@
|
||||
-"""Baseline Python service: decorators, f-strings, indentation, comments."""
|
||||
+"""Edited Python service: decorators, f-strings, indentation, comments."""
|
||||
|
||||
import asyncio
|
||||
-from dataclasses import dataclass
|
||||
+import logging
|
||||
+from dataclasses import dataclass, field
|
||||
|
||||
+logger = logging.getLogger(__name__)
|
||||
|
||||
-@dataclass
|
||||
+
|
||||
+@dataclass(frozen=True)
|
||||
class Review:
|
||||
id: str
|
||||
title: str
|
||||
approved: bool = False
|
||||
+ tags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ReviewService:
|
||||
- def __init__(self, timeout: float = 2.0) -> None:
|
||||
+ def __init__(self, timeout: float = 5.0) -> None:
|
||||
self.timeout = timeout
|
||||
self.cache: dict[str, Review] = {}
|
||||
|
||||
async def load(self, review_id: str) -> Review | None:
|
||||
# Return cached reviews before touching the network.
|
||||
if review_id in self.cache:
|
||||
+ logger.debug("cache hit %s", review_id)
|
||||
return self.cache[review_id]
|
||||
- await asyncio.sleep(self.timeout)
|
||||
+ try:
|
||||
+ await asyncio.wait_for(asyncio.sleep(0), timeout=self.timeout)
|
||||
+ except asyncio.TimeoutError:
|
||||
+ return None
|
||||
return None
|
||||
|
||||
def summary(self, review: Review) -> str:
|
||||
- status = "approved" if review.approved else "pending"
|
||||
- return f"Review {review.id}: {review.title} ({status})"
|
||||
+ status = "approved" if review.approved else "needs review"
|
||||
+ return f"Review {review.id}: {review.title!r} ({status})"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
service = ReviewService()
|
||||
- review = Review(id="r-1", title="Checkout")
|
||||
+ review = Review(id="r-1", title="Checkout", tags=["ui"])
|
||||
print(service.summary(review))
|
||||
|
||||
|
||||
diff --git a/diff-color-fixture/10-moved-block.ts b/diff-color-fixture/10-moved-block.ts
|
||||
index fbd5ecba84..214094d8eb 100644
|
||||
--- a/diff-color-fixture/10-moved-block.ts
|
||||
+++ b/diff-color-fixture/10-moved-block.ts
|
||||
@@ -3,11 +3,6 @@ export function first() {
|
||||
return "first"
|
||||
}
|
||||
|
||||
-export function second() {
|
||||
- const values = [1, 2, 3]
|
||||
- return values.map((value) => value * 2)
|
||||
-}
|
||||
-
|
||||
export function third() {
|
||||
return "third"
|
||||
}
|
||||
@@ -16,4 +11,9 @@ export function fourth() {
|
||||
return "fourth"
|
||||
}
|
||||
|
||||
-export const order = ["first", "second", "third", "fourth"]
|
||||
+export function second() {
|
||||
+ const values = [1, 2, 3]
|
||||
+ return values.map((value) => value * 2)
|
||||
+}
|
||||
+
|
||||
+export const order = ["first", "third", "fourth", "second"]
|
||||
diff --git a/diff-color-fixture/11-deploy.sh b/diff-color-fixture/11-deploy.sh
|
||||
index 5390c2fe9f..ae367067d6 100644
|
||||
--- a/diff-color-fixture/11-deploy.sh
|
||||
+++ b/diff-color-fixture/11-deploy.sh
|
||||
@@ -1,17 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
-# Baseline shell script: variables, quoting, conditionals.
|
||||
+# Edited shell script: variables, quoting, conditionals.
|
||||
set -euo pipefail
|
||||
|
||||
ENVIRONMENT="${1:-staging}"
|
||||
-REGION="us-east-1"
|
||||
-RETRIES=3
|
||||
+REGION="${REGION:-eu-west-1}"
|
||||
+RETRIES=5
|
||||
|
||||
log() {
|
||||
- echo "[deploy] $*"
|
||||
+ printf '[deploy] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
if [[ "$ENVIRONMENT" == "production" ]]; then
|
||||
log "Deploying to production in $REGION"
|
||||
+elif [[ "$ENVIRONMENT" == "preview" ]]; then
|
||||
+ log "Skipping preview deploy"
|
||||
+ exit 0
|
||||
else
|
||||
log "Deploying to $ENVIRONMENT"
|
||||
fi
|
||||
diff --git a/diff-color-fixture/12-pipeline.yml b/diff-color-fixture/12-pipeline.yml
|
||||
index 1fbd5aa75c..251a7e4372 100644
|
||||
--- a/diff-color-fixture/12-pipeline.yml
|
||||
+++ b/diff-color-fixture/12-pipeline.yml
|
||||
@@ -1,19 +1,20 @@
|
||||
name: review
|
||||
on:
|
||||
push:
|
||||
- branches: [v2]
|
||||
+ branches: [v2, dev]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
- runs-on: ubuntu-latest
|
||||
- timeout-minutes: 10
|
||||
+ runs-on: ubuntu-24.04
|
||||
+ timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
+ - uses: oven-sh/setup-bun@v2
|
||||
- name: Install
|
||||
- run: bun install
|
||||
+ run: bun install --frozen-lockfile
|
||||
- name: Check
|
||||
run: bun run check
|
||||
env:
|
||||
CI: true
|
||||
- LOG_LEVEL: info
|
||||
+ LOG_LEVEL: debug
|
||||
diff --git a/diff-color-fixture/13-unicode.md b/diff-color-fixture/13-unicode.md
|
||||
index 1470ef9294..5bd43c3b1f 100644
|
||||
--- a/diff-color-fixture/13-unicode.md
|
||||
+++ b/diff-color-fixture/13-unicode.md
|
||||
@@ -1,12 +1,12 @@
|
||||
# Unicode and mixed scripts
|
||||
|
||||
-Emoji: ✅ ready, ⚠️ warning, ❌ failed.
|
||||
-CJK: 代码审查 完成。
|
||||
-Japanese: レビューを開始します。
|
||||
+Emoji: ✅ ready, ⚠️ warning, ❌ failed, 🚀 shipped.
|
||||
+CJK: 代码审查 已完成。
|
||||
+Japanese: レビューを完了しました。
|
||||
Korean: 변경 사항을 검토합니다.
|
||||
-Arabic (RTL): مراجعة التغييرات
|
||||
+Arabic (RTL): مراجعة جميع التغييرات
|
||||
Hebrew (RTL): סקירת שינויים
|
||||
-Accents: café, naïve, résumé.
|
||||
-Math: a ≤ b, x ≠ y, ∑ values.
|
||||
+Accents: café, naïve, résumé, déjà vu.
|
||||
+Math: a ≥ b, x ≠ y, ∏ values.
|
||||
Box: ┌─┐ │ │ └─┘
|
||||
Combining: e\u0301 vs é
|
||||
diff --git a/diff-color-fixture/14-tabs.go b/diff-color-fixture/14-tabs.go
|
||||
index ab9aca86dd..53e5e11a7c 100644
|
||||
--- a/diff-color-fixture/14-tabs.go
|
||||
+++ b/diff-color-fixture/14-tabs.go
|
||||
@@ -7,11 +7,12 @@ type Review struct {
|
||||
ID string
|
||||
Title string
|
||||
Approved bool
|
||||
+ Tags []string
|
||||
}
|
||||
|
||||
func (r Review) Summary() string {
|
||||
status := "pending"
|
||||
- if r.Approved {
|
||||
+ if r.Approved {
|
||||
status = "approved"
|
||||
}
|
||||
return fmt.Sprintf("%s: %s (%s)", r.ID, r.Title, status)
|
||||
diff --git a/diff-color-fixture/15-empty-to-content.txt b/diff-color-fixture/15-empty-to-content.txt
|
||||
index e69de29bb2..aa4be0acdb 100644
|
||||
--- a/diff-color-fixture/15-empty-to-content.txt
|
||||
+++ b/diff-color-fixture/15-empty-to-content.txt
|
||||
@@ -0,0 +1,2 @@
|
||||
+This tracked file was empty in the baseline.
|
||||
+Every row should render as an addition, without a file-added badge.
|
||||
diff --git a/diff-color-fixture/16-content-to-empty.txt b/diff-color-fixture/16-content-to-empty.txt
|
||||
index bb6e474b37..e69de29bb2 100644
|
||||
--- a/diff-color-fixture/16-content-to-empty.txt
|
||||
+++ b/diff-color-fixture/16-content-to-empty.txt
|
||||
@@ -1,3 +0,0 @@
|
||||
-This tracked file keeps existing but loses all content.
|
||||
-Every row should render as a deletion.
|
||||
-The file itself is not deleted.
|
||||
diff --git a/diff-color-fixture/17-no-newline.txt b/diff-color-fixture/17-no-newline.txt
|
||||
index 51b84a61e2..dc6945a1fa 100644
|
||||
--- a/diff-color-fixture/17-no-newline.txt
|
||||
+++ b/diff-color-fixture/17-no-newline.txt
|
||||
@@ -1,2 +1,2 @@
|
||||
First line
|
||||
-Last line without trailing newline
|
||||
\ No newline at end of file
|
||||
+Last line now with trailing newline
|
||||
diff --git a/diff-color-fixture/18-page.html b/diff-color-fixture/18-page.html
|
||||
index bc1b2d9daa..9f4df11c24 100644
|
||||
--- a/diff-color-fixture/18-page.html
|
||||
+++ b/diff-color-fixture/18-page.html
|
||||
@@ -2,13 +2,14 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
- <title>Review</title>
|
||||
+ <meta name="viewport" content="width=device-width" />
|
||||
+ <title>Review changes</title>
|
||||
</head>
|
||||
- <body class="light">
|
||||
- <main id="app" data-state="idle">
|
||||
- <h1>Old heading</h1>
|
||||
- <button type="button" disabled>Continue</button>
|
||||
- <!-- baseline comment -->
|
||||
+ <body class="dark">
|
||||
+ <main id="app" data-state="ready">
|
||||
+ <h1>New heading</h1>
|
||||
+ <button type="button">Continue</button>
|
||||
+ <!-- edited comment -->
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
diff --git a/diff-color-fixture/19-query.sql b/diff-color-fixture/19-query.sql
|
||||
index b078ad0869..88b64001e0 100644
|
||||
--- a/diff-color-fixture/19-query.sql
|
||||
+++ b/diff-color-fixture/19-query.sql
|
||||
@@ -1,6 +1,6 @@
|
||||
-SELECT id, title, status
|
||||
+SELECT id, title, status, author_id
|
||||
FROM reviews
|
||||
-WHERE status = 'pending'
|
||||
- AND created_at > NOW() - INTERVAL '7 days'
|
||||
-ORDER BY created_at DESC
|
||||
-LIMIT 50;
|
||||
+WHERE status IN ('pending', 'blocked')
|
||||
+ AND created_at > NOW() - INTERVAL '14 days'
|
||||
+ORDER BY updated_at DESC
|
||||
+LIMIT 100;
|
||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 194 KiB |
@@ -0,0 +1 @@
|
||||
local.json
|
||||
@@ -0,0 +1,697 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Diff color tuner</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: light-dark(#fafafa, #121212);
|
||||
--panel: light-dark(#ffffff, #1b1b1b);
|
||||
--line: light-dark(#e6e6e6, #2c2c2c);
|
||||
--text: light-dark(#161616, #ededed);
|
||||
--muted: light-dark(#6b6b6b, #9a9a9a);
|
||||
--accent: #3b5cf6;
|
||||
font: 13px/20px Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); }
|
||||
header {
|
||||
position: sticky; top: 0; z-index: 5; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
header h1 { font-size: 14px; line-height: 20px; margin: 0 8px 0 0; font-weight: 600; }
|
||||
button, select, input[type="text"] {
|
||||
font: inherit; color: inherit; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 6px; height: 28px; padding: 0 8px;
|
||||
}
|
||||
button { cursor: default; }
|
||||
button:hover { background: light-dark(#f2f2f2, #242424); }
|
||||
label.toggle { display: inline-flex; gap: 6px; align-items: center; }
|
||||
#status { color: var(--muted); margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
main { padding: 12px 16px 48px; max-width: 1180px; }
|
||||
h2 { font-size: 12px; line-height: 16px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 20px 0 8px; font-weight: 600; }
|
||||
dialog { width: min(720px, calc(100vw - 32px)); padding: 16px; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 32px #0004; }
|
||||
.sets { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 8px; }
|
||||
.sets label { font-weight: 600; }
|
||||
#set { min-width: 200px; }
|
||||
#name-input { width: 100%; margin: 8px 0 12px; }
|
||||
dialog::backdrop { background: #0006; }
|
||||
dialog h3 { margin: 0 0 4px; font-size: 14px; line-height: 20px; }
|
||||
#paste-text { width: 100%; height: 280px; margin-top: 8px; padding: 8px; resize: vertical; color: var(--text); background: var(--bg);
|
||||
border: 1px solid var(--line); border-radius: 6px; font: 12px/18px "IBM Plex Mono", ui-monospace, monospace; }
|
||||
#paste-result { min-height: 16px; }
|
||||
#paste-result[data-error] { color: #d92e3c; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
h2 { display: flex; align-items: center; gap: 12px; }
|
||||
.group-toggle { text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 12px; color: var(--text); }
|
||||
.grid.off { opacity: .45; }
|
||||
.grid { display: grid; grid-template-columns: minmax(220px, 1.1fr) 1fr 1fr; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); overflow: hidden; }
|
||||
.grid > div { padding: 8px 10px; border-top: 1px solid var(--line); min-width: 0; }
|
||||
.grid > .head { border-top: 0; font-weight: 600; color: var(--muted); font-size: 12px; line-height: 16px; }
|
||||
.slot-label { font-weight: 500; }
|
||||
.slot-help { color: var(--muted); font-size: 12px; line-height: 16px; }
|
||||
.cell { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.cell[data-mode="dark"] { background: light-dark(#f6f6f6, #161616); }
|
||||
.swatch { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--line); flex: none;
|
||||
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
|
||||
background-size: 8px 8px; background-position: 0 0, 4px 4px; position: relative; overflow: hidden; }
|
||||
.swatch::after { content: ""; position: absolute; inset: 0; background: var(--swatch, transparent); }
|
||||
.swatch[data-current]::after { background: repeating-linear-gradient(-45deg, transparent 0 4px, light-dark(#0001, #fff1) 4px 8px); }
|
||||
input[type="color"] { width: 36px; height: 28px; padding: 0 2px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }
|
||||
input[type="range"] { width: 90px; }
|
||||
button.token { display: inline-flex; align-items: center; gap: 6px; min-width: 200px; max-width: 100%; text-align: left; }
|
||||
button.token .token-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px/16px "IBM Plex Mono", ui-monospace, monospace; }
|
||||
button.token .caret { color: var(--muted); }
|
||||
.mini { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--line); background: var(--swatch, transparent); flex: none; }
|
||||
#picker { position: fixed; z-index: 50; width: 560px; max-width: calc(100vw - 16px); max-height: min(520px, calc(100vh - 16px)); display: flex; flex-direction: column;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 32px #0003; }
|
||||
.picker-head { display: flex; gap: 8px; align-items: center; padding: 8px; border-bottom: 1px solid var(--line); }
|
||||
#picker-search { flex: 1; }
|
||||
#picker-mode { color: var(--muted); font-size: 12px; }
|
||||
#picker-body { overflow: auto; padding: 4px 8px 8px; }
|
||||
.family { margin-top: 8px; }
|
||||
.family-name { color: var(--muted); font-size: 11px; line-height: 16px; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.chip { width: 36px; height: 28px; padding: 0; border-radius: 5px; border: 1px solid var(--line); position: relative; overflow: hidden;
|
||||
background-image: linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
|
||||
background-size: 8px 8px; background-position: 0 0, 4px 4px; }
|
||||
.chip::after { content: ""; position: absolute; inset: 0; background: var(--swatch); }
|
||||
.chip span { position: absolute; z-index: 1; inset: auto 0 1px; font-size: 9px; line-height: 10px; text-align: center; color: var(--chip-text); }
|
||||
.chip[aria-selected="true"] { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.rows { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 8px; }
|
||||
.row { display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 6px; border: 0; background: transparent; text-align: left; min-width: 0; }
|
||||
.row:hover, .row[aria-selected="true"] { background: light-dark(#f0f0f0, #262626); }
|
||||
.row[aria-selected="true"] { box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
.row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 12px/16px "IBM Plex Mono", ui-monospace, monospace; }
|
||||
.alpha { display: inline-flex; align-items: center; gap: 2px; color: var(--muted); }
|
||||
.alpha-input { width: 56px; height: 28px; padding: 0 6px; font: inherit; font-variant-numeric: tabular-nums; color: var(--text);
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 6px; text-align: right; }
|
||||
.hidden { display: none !important; }
|
||||
pre { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow: auto; font: 12px/18px "IBM Plex Mono", ui-monospace, monospace; max-height: 360px; }
|
||||
.note { color: var(--muted); max-width: 820px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Diff color tuner</h1>
|
||||
<label class="toggle"><input type="checkbox" id="enabled" checked /> Overrides on</label>
|
||||
<button id="undo" disabled title="Undo last change (⌘Z)">Undo</button>
|
||||
<button id="redo" disabled title="Redo (⇧⌘Z)">Redo</button>
|
||||
<button id="reset">Reset all</button>
|
||||
<button id="copy">Copy CSS</button>
|
||||
<button id="paste">Paste CSS</button>
|
||||
<span id="status">Loading…</span>
|
||||
</header>
|
||||
<main>
|
||||
<div class="sets">
|
||||
<label for="set">Override set</label>
|
||||
<select id="set"></select>
|
||||
<button id="set-new">New</button>
|
||||
<button id="set-duplicate">Duplicate</button>
|
||||
<button id="set-rename">Rename</button>
|
||||
<button id="set-delete">Delete</button>
|
||||
<span id="set-note" class="slot-help"></span>
|
||||
</div>
|
||||
<p class="note">
|
||||
Each change is pushed live into open diff views in the dev app and saved to
|
||||
<code>packages/session-ui/src/pierre/diff-color-tuning.ts</code>. “Current” leaves the branch's colors untouched.
|
||||
Picked fills are exact final colors; tokens resolve per mode from OpenCode v2 tokens.
|
||||
</p>
|
||||
<div id="groups"></div>
|
||||
<h2>Generated CSS</h2>
|
||||
<pre id="css"></pre>
|
||||
</main>
|
||||
<dialog id="name-dialog">
|
||||
<form method="dialog">
|
||||
<h3 id="name-title">Name</h3>
|
||||
<input type="text" id="name-input" spellcheck="false" autocomplete="off" />
|
||||
<div class="dialog-actions">
|
||||
<button value="cancel">Cancel</button>
|
||||
<button id="name-ok" value="ok" class="primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="paste-dialog">
|
||||
<form method="dialog">
|
||||
<h3>Paste CSS</h3>
|
||||
<p class="slot-help">Paste CSS copied from this tuner. It replaces the current settings; Undo restores them.</p>
|
||||
<textarea id="paste-text" spellcheck="false" placeholder="/* diff-color-tuner: … */"></textarea>
|
||||
<p id="paste-result" class="slot-help"></p>
|
||||
<div class="dialog-actions">
|
||||
<button value="cancel">Cancel</button>
|
||||
<button id="paste-apply" value="apply" class="primary">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<div id="picker" class="hidden" role="dialog" aria-label="Choose a v2 token">
|
||||
<div class="picker-head">
|
||||
<input type="text" id="picker-search" placeholder="Search tokens, e.g. red, state, 1200" spellcheck="false" autocomplete="off" />
|
||||
<span id="picker-mode"></span>
|
||||
</div>
|
||||
<div id="picker-body"></div>
|
||||
</div>
|
||||
<script type="module">
|
||||
const LIGHT = ':host(:not([data-color-scheme="dark"]))'
|
||||
const DARK = ':host([data-color-scheme="dark"])'
|
||||
const ADD = '[data-line-type="change-addition"]'
|
||||
const DEL = '[data-line-type="change-deletion"]'
|
||||
const CONTEXT = ':is([data-line-type="context"], [data-line-type="context-expanded"])'
|
||||
const ROW = ':is([data-line], [data-no-newline])'
|
||||
const GUTTER = ':is([data-column-number], [data-gutter-buffer])'
|
||||
const exactRow = (sel) => (host, v) => `${host} ${sel} { --diffs-diff-line-mix-target: ${v}; --mix-light: 0%; --mix-dark: 0%; }`
|
||||
const hostVar = (name) => (host, v) => `${host} { ${name}: ${v}; }`
|
||||
const scopedVar = (name) => (host, v) => `${host} :is([data-diff], [data-file]) { ${name}: ${v}; }`
|
||||
const direct = (sel, decl) => (host, v) => `${host} ${sel} { ${decl(v)} }`
|
||||
// Syntax colors are inline style attributes on the token spans, so only !important can override them.
|
||||
const inlineText = (line) => (host, v) =>
|
||||
`${host} ${line} [data-diff-span], ${host} ${line} [data-diff-span] * { color: ${v} !important; }`
|
||||
|
||||
const GROUPS = [
|
||||
["Backgrounds", [
|
||||
{ id: "bg", label: "File background", help: "--diffs-bg. Base for context rows and Pierre's derived mixes.", rule: hostVar("--diffs-bg"), light: "v2-grey-50", dark: "v2-grey-1100" },
|
||||
{ id: "context-row", label: "Unchanged row", help: "Context code rows.", rule: exactRow(`${CONTEXT}${ROW}`), light: "v2-grey-50", dark: "v2-grey-1100" },
|
||||
{ id: "context-gutter", label: "Unchanged gutter", help: "Context line-number cells.", rule: exactRow(`${CONTEXT}${GUTTER}`), light: "v2-grey-50", dark: "v2-grey-1100" },
|
||||
{ id: "fold-row", label: "Folded “unmodified lines” row", help: "--diffs-bg-separator-override.", rule: hostVar("--diffs-bg-separator-override"), light: "v2-grey-100", dark: "v2-grey-1000" },
|
||||
{ id: "buffer-bg", label: "Split empty side", help: "--diffs-bg-context-gutter-override (blank side of split rows).", rule: hostVar("--diffs-bg-context-gutter-override"), light: "v2-grey-100", dark: "v2-grey-1000" },
|
||||
{ id: "buffer-hatch", label: "Split empty side hatch", help: "--diffs-bg-buffer-override (Pierre stripes; OpenCode currently hides them).", rule: hostVar("--diffs-bg-buffer-override"), light: "v2-grey-200", dark: "v2-grey-900" },
|
||||
{ id: "hover", label: "Hover tint", help: "--diffs-bg-hover-override. Mixed in at ~3% light / ~9% dark, not exact.", rule: hostVar("--diffs-bg-hover-override"), light: "v2-grey-1200", dark: "v2-grey-50" },
|
||||
{ id: "selection", label: "Selected lines", help: "--diffs-selection-base (row and number tints derive from it).", rule: scopedVar("--diffs-selection-base"), light: "v2-background-bg-accent", dark: "v2-background-bg-accent" },
|
||||
{ id: "comment", label: "Comment annotation", help: "--diffs-comment-bg.", rule: scopedVar("--diffs-comment-bg"), light: "v2-blue-100", dark: "v2-blue-1200" },
|
||||
]],
|
||||
["Additions", [
|
||||
{ id: "add-row", label: "Added row", help: "Exact fill of added code rows.", rule: exactRow(`${ADD}${ROW}`), light: "v2-green-100", dark: "v2-green-1200" },
|
||||
{ id: "add-gutter", label: "Added gutter", help: "Exact fill of added line-number cells.", rule: exactRow(`${ADD}${GUTTER}`), light: "v2-green-100", dark: "v2-green-1200" },
|
||||
{ id: "add-inline", label: "Added inline highlight", help: "Word/char emphasis span, painted over the row.", rule: direct(`${ADD} [data-diff-span]`, (v) => `background-color: ${v};`), light: "v2-green-200", dark: "v2-green-1100" },
|
||||
{ id: "add-inline-text", label: "Added inline text", help: "Text color inside the highlight. Replaces syntax colors there.", rule: inlineText(ADD), light: "v2-green-1000", dark: "v2-green-300" },
|
||||
{ id: "add-bar", label: "Added change bar", help: "2px gutter bar.", rule: direct(`[data-column-number]${ADD}::before`, (v) => `background-color: ${v}; background-image: none;`), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
|
||||
{ id: "add-number", label: "Added line-number text", help: "--diffs-fg-number-addition-override.", rule: hostVar("--diffs-fg-number-addition-override"), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
|
||||
{ id: "add-seed", label: "Addition seed", help: "--diffs-addition-color-override. Feeds anything above left on Current.", rule: hostVar("--diffs-addition-color-override"), light: "v2-state-fg-success", dark: "v2-state-fg-success" },
|
||||
]],
|
||||
["Deletions", [
|
||||
{ id: "del-row", label: "Deleted row", help: "Exact fill of deleted code rows.", rule: exactRow(`${DEL}${ROW}`), light: "v2-red-100", dark: "v2-red-1200" },
|
||||
{ id: "del-gutter", label: "Deleted gutter", help: "Exact fill of deleted line-number cells.", rule: exactRow(`${DEL}${GUTTER}`), light: "v2-red-100", dark: "v2-red-1200" },
|
||||
{ id: "del-inline", label: "Deleted inline highlight", help: "Word/char emphasis span, painted over the row.", rule: direct(`${DEL} [data-diff-span]`, (v) => `background-color: ${v};`), light: "v2-red-200", dark: "v2-red-1100" },
|
||||
{ id: "del-inline-text", label: "Deleted inline text", help: "Text color inside the highlight. Replaces syntax colors there.", rule: inlineText(DEL), light: "v2-red-1000", dark: "v2-red-300" },
|
||||
{ id: "del-bar", label: "Deleted change bar", help: "2px gutter bar.", rule: direct(`[data-column-number]${DEL}::before`, (v) => `background-color: ${v}; background-image: none;`), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
|
||||
{ id: "del-number", label: "Deleted line-number text", help: "--diffs-fg-number-deletion-override.", rule: hostVar("--diffs-fg-number-deletion-override"), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
|
||||
{ id: "del-seed", label: "Deletion seed", help: "--diffs-deletion-color-override. Feeds anything above left on Current.", rule: hostVar("--diffs-deletion-color-override"), light: "v2-state-fg-danger", dark: "v2-state-fg-danger" },
|
||||
]],
|
||||
["Text", [
|
||||
{ id: "number", label: "Unchanged line-number text", help: "--diffs-fg-number-override. Also fold labels unless set below.", rule: hostVar("--diffs-fg-number-override"), light: "v2-text-text-faint", dark: "v2-text-text-faint" },
|
||||
{ id: "fold-text", label: "Fold label and expand icon", help: "“N unmodified lines” text and expand button.", rule: direct(":is([data-separator-content], [data-expand-button])", (v) => `color: ${v};`), light: "v2-text-text-muted", dark: "v2-text-text-muted" },
|
||||
]],
|
||||
]
|
||||
const SLOTS = GROUPS.flatMap((group) => group[1])
|
||||
|
||||
let tokens = []
|
||||
let tokenMap = new Map()
|
||||
let state = { enabled: true, slots: {} }
|
||||
let liveTimer, saveTimer, liveBusy = false, livePending = false
|
||||
|
||||
const $ = (sel) => document.querySelector(sel)
|
||||
const status = (text) => ($("#status").textContent = text)
|
||||
|
||||
function hex8(hex, alpha) {
|
||||
return alpha >= 100 ? hex : hex + Math.round((alpha / 100) * 255).toString(16).padStart(2, "0")
|
||||
}
|
||||
function tokenHex(name, mode) {
|
||||
const value = tokenMap.get(name)?.[mode]
|
||||
return value && /^#[0-9a-f]{6}/i.test(value) ? value.slice(0, 7) : "#888888"
|
||||
}
|
||||
function valueOf(entry) {
|
||||
if (!entry || entry.kind === "current") return null
|
||||
if (entry.kind === "color") return hex8(entry.hex, entry.alpha)
|
||||
if (!tokenMap.has(entry.token)) return null
|
||||
return entry.alpha >= 100 ? `var(--${entry.token})` : `rgb(from var(--${entry.token}) r g b / ${entry.alpha / 100})`
|
||||
}
|
||||
function swatchOf(entry, mode) {
|
||||
if (!entry || entry.kind === "current") return null
|
||||
if (entry.kind === "color") return hex8(entry.hex, entry.alpha)
|
||||
const value = tokenMap.get(entry.token)?.[mode]
|
||||
if (!value) return null
|
||||
return entry.alpha >= 100 ? value : `rgb(from ${value} r g b / ${entry.alpha / 100})`
|
||||
}
|
||||
|
||||
function buildCSS() {
|
||||
if (!state.enabled) return ""
|
||||
const active = GROUPS.filter(([title]) => !state.disabledGroups?.[title]).flatMap((group) => group[1])
|
||||
return [["light", LIGHT], ["dark", DARK]]
|
||||
.flatMap(([mode, host]) =>
|
||||
active.flatMap((slot) => {
|
||||
const value = valueOf(state.slots[slot.id]?.[mode])
|
||||
return value ? [`/* ${mode}: ${slot.label} */\n${slot.rule(host, value)}`] : []
|
||||
}),
|
||||
)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function entryFor(slot, mode) {
|
||||
state.slots[slot.id] ??= {}
|
||||
state.slots[slot.id][mode] ??= { kind: "current" }
|
||||
return state.slots[slot.id][mode]
|
||||
}
|
||||
|
||||
function render() {
|
||||
const root = $("#groups")
|
||||
root.textContent = ""
|
||||
for (const [title, slots] of GROUPS) {
|
||||
const heading = document.createElement("h2")
|
||||
heading.innerHTML = `<span></span><label class="toggle group-toggle"><input type="checkbox" /> <span>On</span></label>`
|
||||
heading.firstElementChild.textContent = title
|
||||
const enabled = !state.disabledGroups?.[title]
|
||||
const toggle = heading.querySelector("input")
|
||||
toggle.checked = enabled
|
||||
heading.querySelector(".group-toggle span").textContent = enabled ? "On" : "Off"
|
||||
toggle.onchange = () => {
|
||||
state.disabledGroups ??= {}
|
||||
if (toggle.checked) delete state.disabledGroups[title]
|
||||
else state.disabledGroups[title] = true
|
||||
render()
|
||||
changed(true)
|
||||
}
|
||||
const grid = document.createElement("div")
|
||||
grid.className = "grid"
|
||||
grid.classList.toggle("off", !enabled)
|
||||
grid.innerHTML = `<div class="head">Slot</div><div class="head">Light</div><div class="head">Dark</div>`
|
||||
for (const slot of slots) {
|
||||
const info = document.createElement("div")
|
||||
info.innerHTML = `<div class="slot-label"></div><div class="slot-help"></div>`
|
||||
info.children[0].textContent = slot.label
|
||||
info.children[1].textContent = slot.help
|
||||
grid.append(info, cell(slot, "light"), cell(slot, "dark"))
|
||||
}
|
||||
root.append(heading, grid)
|
||||
}
|
||||
$("#enabled").checked = state.enabled
|
||||
$("#css").textContent = buildCSS() || "/* No overrides */"
|
||||
}
|
||||
|
||||
function cell(slot, mode) {
|
||||
const entry = entryFor(slot, mode)
|
||||
const el = document.createElement("div")
|
||||
el.className = "cell"
|
||||
el.dataset.mode = mode
|
||||
el.innerHTML = `
|
||||
<span class="swatch"></span>
|
||||
<select class="kind"><option value="current">Current</option><option value="color">Color</option><option value="token">v2 token</option></select>
|
||||
<input type="color" class="hex" />
|
||||
<button type="button" class="token"><span class="mini"></span><span class="token-name"></span><span class="caret">▾</span></button>
|
||||
<input type="range" class="alpha-range" min="0" max="100" step="1" />
|
||||
<span class="alpha"><input type="number" class="alpha-input" min="0" max="100" step="any" aria-label="Opacity percent" /><span>%</span></span>`
|
||||
const swatch = el.querySelector(".swatch")
|
||||
const kind = el.querySelector(".kind")
|
||||
const hex = el.querySelector(".hex")
|
||||
const token = el.querySelector(".token")
|
||||
const alpha = el.querySelector(".alpha-range")
|
||||
const alphaText = el.querySelector(".alpha")
|
||||
const alphaInput = el.querySelector(".alpha-input")
|
||||
const sync = () => {
|
||||
kind.value = entry.kind
|
||||
hex.classList.toggle("hidden", entry.kind !== "color")
|
||||
token.classList.toggle("hidden", entry.kind !== "token")
|
||||
alpha.classList.toggle("hidden", entry.kind === "current")
|
||||
alphaText.classList.toggle("hidden", entry.kind === "current")
|
||||
if (entry.kind === "color") hex.value = entry.hex
|
||||
if (entry.kind === "token") {
|
||||
token.querySelector(".token-name").textContent = entry.token
|
||||
token.querySelector(".mini").style.setProperty("--swatch", tokenMap.get(entry.token)?.[mode] ?? "transparent")
|
||||
}
|
||||
alpha.value = entry.alpha ?? 100
|
||||
if (document.activeElement !== alphaInput) alphaInput.value = entry.alpha ?? 100
|
||||
const color = swatchOf(entry, mode)
|
||||
if (color) swatch.style.setProperty("--swatch", color)
|
||||
swatch.toggleAttribute("data-current", !color)
|
||||
swatch.title = entry.kind === "token" ? `${entry.token}: ${tokenMap.get(entry.token)?.[mode] ?? "unknown token"}` : (color ?? "Current (unchanged)")
|
||||
token.style.outline = entry.kind === "token" && !tokenMap.has(entry.token) ? "1px solid #d92e3c" : ""
|
||||
}
|
||||
kind.onchange = () => {
|
||||
const previous = { ...entry }
|
||||
entry.kind = kind.value
|
||||
entry.alpha ??= 100
|
||||
if (entry.kind === "color") entry.hex = previous.kind === "token" ? tokenHex(previous.token, mode) : (previous.hex ?? tokenHex(slot[mode], mode))
|
||||
if (entry.kind === "token") entry.token = previous.token ?? slot[mode]
|
||||
sync()
|
||||
changed(true)
|
||||
}
|
||||
hex.oninput = () => { entry.hex = hex.value; sync(); changed(false) }
|
||||
hex.onchange = () => changed(true)
|
||||
token.onclick = () => openPicker(token, mode, entry.token, (name) => { entry.token = name; sync(); changed(true) })
|
||||
alpha.oninput = () => { entry.alpha = Number(alpha.value); sync(); changed(false) }
|
||||
alpha.onchange = () => changed(true)
|
||||
alphaInput.oninput = () => {
|
||||
const value = Number(alphaInput.value)
|
||||
if (alphaInput.value === "" || !Number.isFinite(value)) return
|
||||
entry.alpha = Math.min(100, Math.max(0, value))
|
||||
sync()
|
||||
changed(false)
|
||||
}
|
||||
alphaInput.onchange = () => { alphaInput.value = entry.alpha ?? 100; changed(true) }
|
||||
alphaInput.onkeydown = (event) => { if (event.key === "Enter") alphaInput.blur() }
|
||||
sync()
|
||||
return el
|
||||
}
|
||||
|
||||
const RAMP_ORDER = ["grey", "red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink", "alpha-dark", "alpha-light"]
|
||||
const picker = { anchor: null, mode: "light", selected: "", onPick: null }
|
||||
|
||||
function familyOf(name) {
|
||||
const ramp = /^v2-([a-z]+(?:-dark|-light)?)-(\d+)$/.exec(name)
|
||||
if (ramp && RAMP_ORDER.includes(ramp[1])) return { family: ramp[1], step: ramp[2], ramp: true }
|
||||
return { family: name.split("-")[1] ?? "other", step: "", ramp: false }
|
||||
}
|
||||
function readableOn(value) {
|
||||
const hex = /^#([0-9a-f]{6})/i.exec(value)?.[1]
|
||||
if (!hex) return "#000"
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.55 ? "#0009" : "#fffb"
|
||||
}
|
||||
|
||||
function openPicker(anchor, mode, selected, onPick) {
|
||||
Object.assign(picker, { anchor, mode, selected, onPick })
|
||||
$("#picker-mode").textContent = `${mode} values`
|
||||
$("#picker-search").value = ""
|
||||
$("#picker").classList.remove("hidden")
|
||||
renderPicker()
|
||||
placePicker()
|
||||
$("#picker-search").focus()
|
||||
const current = $(`#picker-body [aria-selected="true"]`)
|
||||
$("#picker-body").scrollTop = current ? current.offsetTop - $("#picker-body").offsetTop - 60 : 0
|
||||
}
|
||||
function closePicker() {
|
||||
$("#picker").classList.add("hidden")
|
||||
picker.anchor?.focus()
|
||||
picker.anchor = null
|
||||
}
|
||||
function placePicker() {
|
||||
const el = $("#picker")
|
||||
const rect = picker.anchor.getBoundingClientRect()
|
||||
const height = el.offsetHeight
|
||||
const top = rect.bottom + 4 + height > innerHeight ? Math.max(8, rect.top - 4 - height) : rect.bottom + 4
|
||||
el.style.top = `${top}px`
|
||||
el.style.left = `${Math.min(Math.max(8, rect.left), innerWidth - el.offsetWidth - 8)}px`
|
||||
}
|
||||
function renderPicker() {
|
||||
const terms = $("#picker-search").value.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
const matches = tokens.filter((token) => terms.every((term) => token.name.includes(term)))
|
||||
const groups = new Map()
|
||||
for (const token of matches) {
|
||||
const info = familyOf(token.name)
|
||||
if (!groups.has(info.family)) groups.set(info.family, { ramp: info.ramp, items: [] })
|
||||
groups.get(info.family).items.push({ token, step: info.step })
|
||||
}
|
||||
const order = [...groups.keys()].sort((a, b) => {
|
||||
const ia = RAMP_ORDER.indexOf(a), ib = RAMP_ORDER.indexOf(b)
|
||||
if (ia !== -1 || ib !== -1) return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const body = $("#picker-body")
|
||||
body.textContent = ""
|
||||
if (!matches.length) body.innerHTML = `<p class="slot-help">No tokens match.</p>`
|
||||
for (const family of order) {
|
||||
const group = groups.get(family)
|
||||
const section = document.createElement("div")
|
||||
section.className = "family"
|
||||
const title = document.createElement("div")
|
||||
title.className = "family-name"
|
||||
title.textContent = family
|
||||
const list = document.createElement("div")
|
||||
list.className = group.ramp ? "chips" : "rows"
|
||||
for (const item of group.items) {
|
||||
const value = item.token[picker.mode]
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.title = `${item.token.name}\nlight ${item.token.light}\ndark ${item.token.dark}`
|
||||
button.setAttribute("aria-selected", String(item.token.name === picker.selected))
|
||||
button.style.setProperty("--swatch", value)
|
||||
if (group.ramp) {
|
||||
button.className = "chip"
|
||||
button.style.setProperty("--chip-text", readableOn(value))
|
||||
button.innerHTML = `<span>${item.step}</span>`
|
||||
} else {
|
||||
button.className = "row"
|
||||
button.innerHTML = `<span class="mini"></span><span class="name"></span>`
|
||||
button.querySelector(".name").textContent = item.token.name.replace(/^v2-/, "")
|
||||
}
|
||||
button.onclick = () => {
|
||||
picker.onPick(item.token.name)
|
||||
closePicker()
|
||||
}
|
||||
list.append(button)
|
||||
}
|
||||
section.append(title, list)
|
||||
body.append(section)
|
||||
}
|
||||
}
|
||||
$("#picker-search").oninput = renderPicker
|
||||
$("#picker-search").onkeydown = (event) => {
|
||||
if (event.key === "Escape") closePicker()
|
||||
if (event.key === "Enter") $("#picker-body button")?.click()
|
||||
}
|
||||
document.addEventListener("mousedown", (event) => {
|
||||
if (!picker.anchor || $("#picker").contains(event.target) || picker.anchor.contains(event.target)) return
|
||||
closePicker()
|
||||
})
|
||||
window.addEventListener("resize", () => picker.anchor && placePicker())
|
||||
document.addEventListener("scroll", () => picker.anchor && placePicker(), true)
|
||||
|
||||
// History holds committed snapshots only, so one drag of a picker or slider is one undo step.
|
||||
const history = { undo: [], redo: [], committed: "" }
|
||||
function record() {
|
||||
const snapshot = JSON.stringify(state)
|
||||
if (snapshot === history.committed) return
|
||||
history.undo.push(history.committed)
|
||||
history.redo = []
|
||||
history.committed = snapshot
|
||||
syncHistory()
|
||||
}
|
||||
function travel(from, to) {
|
||||
if (!from.length) return
|
||||
to.push(history.committed)
|
||||
history.committed = from.pop()
|
||||
state = JSON.parse(history.committed)
|
||||
render()
|
||||
syncHistory()
|
||||
changed(false)
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(save, 150)
|
||||
}
|
||||
function syncHistory() {
|
||||
$("#undo").disabled = !history.undo.length
|
||||
$("#redo").disabled = !history.redo.length
|
||||
}
|
||||
$("#undo").onclick = () => travel(history.undo, history.redo)
|
||||
$("#redo").onclick = () => travel(history.redo, history.undo)
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") return
|
||||
if (event.target instanceof HTMLInputElement && ["text", "number"].includes(event.target.type)) return
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) travel(history.redo, history.undo)
|
||||
else travel(history.undo, history.redo)
|
||||
})
|
||||
|
||||
// Live pushes stay responsive while dragging; file writes are debounced to limit dev-server hot reloads.
|
||||
function changed(commit) {
|
||||
if (commit) record()
|
||||
const css = buildCSS()
|
||||
$("#css").textContent = css || "/* No overrides */"
|
||||
clearTimeout(liveTimer)
|
||||
liveTimer = setTimeout(() => live(css), 30)
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(save, commit ? 150 : 700)
|
||||
status("Saving…")
|
||||
}
|
||||
async function live(css) {
|
||||
if (liveBusy) { livePending = true; return }
|
||||
liveBusy = true
|
||||
await fetch("/api/live", { method: "POST", body: JSON.stringify({ css }) }).catch(() => {})
|
||||
liveBusy = false
|
||||
if (livePending) { livePending = false; live(buildCSS()) }
|
||||
}
|
||||
async function save() {
|
||||
// The committed reference set is read-only; the first edit forks it so the reference stays intact.
|
||||
if (current.readonly) {
|
||||
const name = `${state.name} copy`
|
||||
const created = await fetch("/api/sets", { method: "POST", body: JSON.stringify({ name, set: state }) }).then((r) => r.json())
|
||||
state.name = name
|
||||
state.readonly = false
|
||||
await loadSets(created.id)
|
||||
$("#set-note").textContent = `Reference is read-only, so your edits were saved as “${name}”.`
|
||||
}
|
||||
const response = await fetch(`/api/sets/${current.id}`, { method: "PUT", body: JSON.stringify({ set: state, css: buildCSS() }) }).catch(() => null)
|
||||
if (!response?.ok) return status("Save failed — is server.ts running?")
|
||||
const result = await response.json()
|
||||
status(`Saved ${new Date().toLocaleTimeString()} · live in ${result.live} diff view${result.live === 1 ? "" : "s"}`)
|
||||
}
|
||||
|
||||
$("#enabled").onchange = () => { state.enabled = $("#enabled").checked; render(); changed(true) }
|
||||
$("#reset").onclick = () => { if (!confirm("Reset every slot to Current?")) return; state = { ...state, enabled: true, disabledGroups: {}, slots: {} }; render(); changed(true) }
|
||||
// Shared CSS carries the exact settings in a header so pasting restores token choices, opacity and category toggles.
|
||||
function shareableCSS() {
|
||||
const css = buildCSS()
|
||||
if (!css) return ""
|
||||
const slots = Object.fromEntries(
|
||||
Object.entries(state.slots).flatMap(([id, modes]) => {
|
||||
const active = Object.fromEntries(Object.entries(modes).filter(([, entry]) => entry.kind !== "current"))
|
||||
return Object.keys(active).length ? [[id, active]] : []
|
||||
}),
|
||||
)
|
||||
const settings = { enabled: state.enabled, disabledGroups: state.disabledGroups ?? {}, slots }
|
||||
return `/* diff-color-tuner: ${JSON.stringify(settings)} */\n${css}`
|
||||
}
|
||||
|
||||
function parseShared(text) {
|
||||
// Chat apps often render "*…*" as Markdown emphasis and drop the asterisks, so don't require comment delimiters.
|
||||
// The settings JSON is always on one line, so a greedy match within the line is safe.
|
||||
const header = /diff-color-tuner:\s*(\{.*\})/.exec(text)
|
||||
if (header) {
|
||||
const settings = JSON.parse(header[1])
|
||||
const slots = Object.fromEntries(Object.entries(settings.slots ?? {}).filter(([id]) => SLOTS.some((slot) => slot.id === id)))
|
||||
return { state: { enabled: settings.enabled ?? true, disabledGroups: settings.disabledGroups ?? {}, slots }, count: Object.values(slots).reduce((sum, modes) => sum + Object.keys(modes).length, 0) }
|
||||
}
|
||||
// Fallback for CSS without the header: read each "/* mode: Label */" comment and the first color value after it.
|
||||
const slots = {}
|
||||
let count = 0
|
||||
for (const match of text.matchAll(/^\s*\/\*?\s*(light|dark):\s*(.+?)\s*\*?\/\s*\n\s*([^\n]+)/gm)) {
|
||||
const label = (value) => value.replace(/[“”"]/g, '"').replace(/\s*\*$/, "")
|
||||
const slot = SLOTS.find((candidate) => label(candidate.label) === label(match[2]))
|
||||
const entry = slot && parseValue(match[3].slice(match[3].indexOf("{")))
|
||||
if (!entry) continue
|
||||
slots[slot.id] ??= {}
|
||||
slots[slot.id][match[1]] = entry
|
||||
count++
|
||||
}
|
||||
return count ? { state: { enabled: true, disabledGroups: {}, slots }, count } : null
|
||||
}
|
||||
|
||||
function parseValue(rule) {
|
||||
const alphaToken = /rgb\(from var\(--([\w-]+)\) r g b \/ ([\d.]+)\)/.exec(rule)
|
||||
if (alphaToken && tokenMap.has(alphaToken[1])) return { kind: "token", token: alphaToken[1], alpha: Number(alphaToken[2]) * 100 }
|
||||
const token = /var\(--(v2-[\w-]+)\)/.exec(rule)
|
||||
if (token && tokenMap.has(token[1])) return { kind: "token", token: token[1], alpha: 100 }
|
||||
const hex = /#([0-9a-f]{6})([0-9a-f]{2})?\b/i.exec(rule)
|
||||
if (hex) return { kind: "color", hex: `#${hex[1].toLowerCase()}`, alpha: hex[2] ? Math.round((parseInt(hex[2], 16) / 255) * 1000) / 10 : 100 }
|
||||
return null
|
||||
}
|
||||
|
||||
$("#paste").onclick = () => {
|
||||
$("#paste-text").value = ""
|
||||
$("#paste-result").textContent = ""
|
||||
$("#paste-result").removeAttribute("data-error")
|
||||
$("#paste-dialog").showModal()
|
||||
$("#paste-text").focus()
|
||||
}
|
||||
$("#paste-apply").onclick = (event) => {
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = parseShared($("#paste-text").value)
|
||||
} catch {}
|
||||
if (!parsed) {
|
||||
event.preventDefault()
|
||||
$("#paste-result").textContent = "No tuner rules found in that text."
|
||||
$("#paste-result").setAttribute("data-error", "")
|
||||
return
|
||||
}
|
||||
state = { ...parsed.state, name: state.name, readonly: state.readonly }
|
||||
render()
|
||||
changed(true)
|
||||
status(`Applied ${parsed.count} pasted override${parsed.count === 1 ? "" : "s"}`)
|
||||
}
|
||||
|
||||
$("#copy").onclick = async () => {
|
||||
const css = shareableCSS()
|
||||
if (!css) return status("Nothing to copy — no active overrides")
|
||||
const result = await fetch("/api/copy", { method: "POST", body: css }).then((r) => r.json()).catch(() => null)
|
||||
if (result?.ok) return status(`Copied ${css.split("\n").filter((line) => /^\/\* (light|dark):/.test(line)).length} rules`)
|
||||
const copied = await navigator.clipboard.writeText(css).then(() => true, () => false)
|
||||
status(copied ? "CSS copied" : "Copy failed — select the CSS at the bottom manually")
|
||||
}
|
||||
|
||||
let current = { id: "reference", readonly: true }
|
||||
|
||||
async function loadSets(selected) {
|
||||
const data = await fetch("/api/sets").then((r) => r.json())
|
||||
const id = selected ?? data.active
|
||||
$("#set").innerHTML = ""
|
||||
for (const set of data.sets) {
|
||||
const option = document.createElement("option")
|
||||
option.value = set.id
|
||||
option.textContent = set.readonly ? `${set.name} (read-only)` : set.name
|
||||
$("#set").append(option)
|
||||
}
|
||||
const match = data.sets.find((set) => set.id === id) ?? data.sets[0]
|
||||
current = { id: match.id, readonly: match.readonly }
|
||||
$("#set").value = current.id
|
||||
$("#set-rename").disabled = current.readonly
|
||||
$("#set-delete").disabled = current.readonly
|
||||
return current.id
|
||||
}
|
||||
|
||||
async function switchSet(id) {
|
||||
clearTimeout(saveTimer)
|
||||
await loadSets(id)
|
||||
state = await fetch(`/api/sets/${current.id}`).then((r) => r.json())
|
||||
history.undo = []
|
||||
history.redo = []
|
||||
history.committed = JSON.stringify(state)
|
||||
syncHistory()
|
||||
render()
|
||||
$("#set-note").textContent = current.readonly ? "Edits to the reference are saved as a new set." : ""
|
||||
const result = await fetch("/api/active", { method: "POST", body: JSON.stringify({ id: current.id, css: buildCSS() }) }).then((r) => r.json())
|
||||
status(`Showing “${state.name}” · live in ${result.live} diff view${result.live === 1 ? "" : "s"}`)
|
||||
}
|
||||
|
||||
// window.prompt isn't available in Electron, so names come from an in-page dialog.
|
||||
function askName(title, value) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = $("#name-dialog")
|
||||
$("#name-title").textContent = title
|
||||
$("#name-input").value = value
|
||||
dialog.onclose = () => resolve(dialog.returnValue === "ok" ? $("#name-input").value.trim() || null : null)
|
||||
dialog.returnValue = ""
|
||||
$("#name-input").onkeydown = (event) => {
|
||||
if (event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
dialog.close("ok")
|
||||
}
|
||||
dialog.showModal()
|
||||
$("#name-input").select()
|
||||
})
|
||||
}
|
||||
|
||||
async function createSet(name, set) {
|
||||
const created = await fetch("/api/sets", { method: "POST", body: JSON.stringify({ name, set }) }).then((r) => r.json())
|
||||
await switchSet(created.id)
|
||||
}
|
||||
|
||||
$("#set").onchange = () => switchSet($("#set").value)
|
||||
$("#set-new").onclick = async () => {
|
||||
const name = await askName("New override set", "My overrides")
|
||||
if (name) await createSet(name, { enabled: true, disabledGroups: {}, slots: {} })
|
||||
}
|
||||
$("#set-duplicate").onclick = async () => {
|
||||
const name = await askName("Duplicate set", `${state.name} copy`)
|
||||
if (name) await createSet(name, state)
|
||||
}
|
||||
$("#set-rename").onclick = async () => {
|
||||
const name = await askName("Rename set", state.name)
|
||||
if (!name) return
|
||||
state.name = name
|
||||
await save()
|
||||
await loadSets(current.id)
|
||||
}
|
||||
$("#set-delete").onclick = async () => {
|
||||
if (!confirm(`Delete “${state.name}”?`)) return
|
||||
await fetch(`/api/sets/${current.id}`, { method: "DELETE" })
|
||||
await switchSet("reference")
|
||||
}
|
||||
|
||||
const tokenList = await fetch("/api/tokens").then((r) => r.json())
|
||||
tokens = tokenList
|
||||
tokenMap = new Map(tokens.map((token) => [token.name, token]))
|
||||
await switchSet()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,201 @@
|
||||
// Temporary diff color tuner. Delete this directory, packages/session-ui/src/pierre/diff-color-tuning.ts,
|
||||
// and the matching import/interpolation in packages/session-ui/src/pierre/index.ts when finished.
|
||||
import path from "node:path"
|
||||
import { resolveThemeVariantV2 } from "../../packages/ui/src/theme/v2/resolve"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../..")
|
||||
const setsDir = path.join(import.meta.dir, "sets")
|
||||
const localPath = path.join(import.meta.dir, "local.json")
|
||||
const outputPath = path.join(root, "packages/session-ui/src/pierre/diff-color-tuning.ts")
|
||||
const cdp = process.env.TUNER_CDP ?? "http://127.0.0.1:9222"
|
||||
const port = Number(process.env.TUNER_PORT ?? 4455)
|
||||
|
||||
const theme = await Bun.file(path.join(root, "packages/ui/src/theme/themes/oc-2.json")).json()
|
||||
// Alpha ramps are static CSS, not part of the resolved theme.
|
||||
const alpha = Object.fromEntries(
|
||||
[...(await Bun.file(path.join(root, "packages/ui/src/styles/tokens/colors.css")).text()).matchAll(
|
||||
/--(v2-alpha-(?:dark|light)-\d+):\s*([^;]+);/g,
|
||||
)].map((match) => [match[1]!, match[2]!.trim()]),
|
||||
)
|
||||
const tokens = buildTokens()
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
routes: {
|
||||
"/": () => new Response(Bun.file(path.join(import.meta.dir, "index.html"))),
|
||||
"/api/tokens": Response.json(tokens),
|
||||
"/api/sets": {
|
||||
GET: async () => Response.json({ active: await activeSet(), sets: await listSets() }),
|
||||
POST: async (request) => {
|
||||
const body = await request.json()
|
||||
const id = await uniqueID(body.name)
|
||||
await writeSet(id, { ...body.set, name: body.name, readonly: false })
|
||||
return Response.json({ id })
|
||||
},
|
||||
},
|
||||
"/api/sets/:id": {
|
||||
GET: async (request) => {
|
||||
const file = Bun.file(setPath(request.params.id))
|
||||
return (await file.exists()) ? Response.json(await file.json()) : new Response("Not found", { status: 404 })
|
||||
},
|
||||
PUT: async (request) => {
|
||||
const id = request.params.id
|
||||
const current = await readSet(id)
|
||||
if (current?.readonly) return new Response("Read-only set", { status: 403 })
|
||||
const body = await request.json()
|
||||
await writeSet(id, { ...body.set, name: body.set.name ?? current?.name ?? id, readonly: false })
|
||||
return Response.json(await apply(id, body.css))
|
||||
},
|
||||
DELETE: async (request) => {
|
||||
const current = await readSet(request.params.id)
|
||||
if (!current || current.readonly) return new Response("Cannot delete", { status: 403 })
|
||||
await Bun.file(setPath(request.params.id)).delete()
|
||||
return Response.json({ ok: true })
|
||||
},
|
||||
},
|
||||
"/api/active": {
|
||||
POST: async (request) => {
|
||||
const body = await request.json()
|
||||
return Response.json(await apply(body.id, body.css))
|
||||
},
|
||||
},
|
||||
// The embedded browser pane may deny navigator.clipboard, so copy through the OS clipboard instead.
|
||||
"/api/copy": {
|
||||
POST: async (request) => {
|
||||
const process = Bun.spawn(["pbcopy"], { stdin: new Blob([await request.text()]) })
|
||||
return Response.json({ ok: (await process.exited) === 0 })
|
||||
},
|
||||
},
|
||||
"/api/live": {
|
||||
POST: async (request) => Response.json({ live: await push((await request.json()).css) }),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Diff color tuner: http://127.0.0.1:${server.port}`)
|
||||
|
||||
type OverrideSet = { name: string; readonly?: boolean; enabled: boolean; disabledGroups?: Record<string, boolean>; slots: Record<string, unknown> }
|
||||
|
||||
function setPath(id: string) {
|
||||
return path.join(setsDir, `${id.replace(/[^\w-]/g, "")}.json`)
|
||||
}
|
||||
|
||||
async function readSet(id: string): Promise<OverrideSet | undefined> {
|
||||
const file = Bun.file(setPath(id))
|
||||
return (await file.exists()) ? file.json() : undefined
|
||||
}
|
||||
|
||||
function writeSet(id: string, set: OverrideSet) {
|
||||
return Bun.write(setPath(id), JSON.stringify(set, null, 2) + "\n")
|
||||
}
|
||||
|
||||
async function listSets() {
|
||||
const ids = [...new Bun.Glob("*.json").scanSync(setsDir)].map((file) => file.slice(0, -5))
|
||||
const sets = await Promise.all(ids.map(async (id) => ({ id, ...(await readSet(id))! })))
|
||||
return sets
|
||||
.map((set) => ({ id: set.id, name: set.name, readonly: Boolean(set.readonly) }))
|
||||
.sort((a, b) => Number(b.readonly) - Number(a.readonly) || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
async function activeSet() {
|
||||
const file = Bun.file(localPath)
|
||||
const active = (await file.exists()) ? (await file.json()).active : undefined
|
||||
return active && (await readSet(active)) ? active : "reference"
|
||||
}
|
||||
|
||||
async function uniqueID(name: string) {
|
||||
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "set"
|
||||
const taken = new Set((await listSets()).map((set) => set.id))
|
||||
return Array.from({ length: 100 }, (_, i) => (i ? `${base}-${i + 1}` : base)).find((id) => !taken.has(id))!
|
||||
}
|
||||
|
||||
// Activating a set always rewrites the tuning module and pushes live CSS, so the dev app shows exactly the selected set.
|
||||
async function apply(id: string, css: string) {
|
||||
await Bun.write(localPath, JSON.stringify({ active: id }, null, 2) + "\n")
|
||||
await Bun.write(outputPath, renderModule(css))
|
||||
return { live: await push(css) }
|
||||
}
|
||||
|
||||
function renderModule(css: string) {
|
||||
const escaped = css.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
|
||||
return `// Temporary diff color tuning output. Generated by diff-color-review-artifacts/tuner; delete with the tuner.\nexport const diffColorTuningCSS = \`${escaped}\`\n`
|
||||
}
|
||||
|
||||
function buildTokens() {
|
||||
const light = { ...alpha, ...resolveThemeVariantV2(theme.light, false) }
|
||||
const dark = { ...alpha, ...resolveThemeVariantV2(theme.dark, true) }
|
||||
const resolve = (map: Record<string, string>, value: string, depth = 0): string => {
|
||||
const match = /^var\(--([\w-]+)\)$/.exec(value.trim())
|
||||
if (!match || depth > 8) return value
|
||||
return map[match[1]!] ? resolve(map, map[match[1]!]!, depth + 1) : value
|
||||
}
|
||||
return Object.keys(light)
|
||||
.filter((name) => !name.includes("elevation") && !name.includes("font"))
|
||||
.map((name) => ({ name, light: resolve(light, light[name]!), dark: resolve(dark, dark[name] ?? light[name]!) }))
|
||||
.filter((token) => /^(#|rgb|hsl|oklch)/.test(token.light))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
// Live preview: Pierre reads unsafeCSS when a viewer mounts, so already-open diffs need the CSS pushed into their shadow roots.
|
||||
async function push(css: string) {
|
||||
const targets: { type: string; webSocketDebuggerUrl: string }[] = await fetch(`${cdp}/json/list`)
|
||||
.then((response) => response.json())
|
||||
.catch(() => [])
|
||||
const counts = await Promise.all(
|
||||
targets.filter((target) => target.type === "page").map((target) => evaluate(target.webSocketDebuggerUrl, injector(css))),
|
||||
)
|
||||
return counts.reduce<number>((sum, count) => sum + (typeof count === "number" ? count : 0), 0)
|
||||
}
|
||||
|
||||
function injector(css: string) {
|
||||
return `(() => {
|
||||
window.__diffColorTuning = ${JSON.stringify(css)}
|
||||
const apply = (host) => {
|
||||
const root = host.shadowRoot
|
||||
if (!root) return
|
||||
let style = root.querySelector("style[data-diff-color-tuning]")
|
||||
if (!style) {
|
||||
style = document.createElement("style")
|
||||
style.dataset.diffColorTuning = ""
|
||||
root.appendChild(style)
|
||||
}
|
||||
if (style.textContent !== window.__diffColorTuning) style.textContent = window.__diffColorTuning
|
||||
}
|
||||
const all = () => document.querySelectorAll("diffs-container").forEach(apply)
|
||||
all()
|
||||
if (!window.__diffColorTuningObserver) {
|
||||
let queued = false
|
||||
window.__diffColorTuningObserver = new MutationObserver(() => {
|
||||
if (queued) return
|
||||
queued = true
|
||||
requestAnimationFrame(() => { queued = false; all() })
|
||||
})
|
||||
window.__diffColorTuningObserver.observe(document.body, { childList: true, subtree: true })
|
||||
}
|
||||
return document.querySelectorAll("diffs-container").length
|
||||
})()`
|
||||
}
|
||||
|
||||
function evaluate(url: string, expression: string) {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
const socket = new WebSocket(url)
|
||||
const timer = setTimeout(() => {
|
||||
socket.close()
|
||||
resolve(undefined)
|
||||
}, 2000)
|
||||
socket.onopen = () =>
|
||||
socket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression, returnByValue: true } }))
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(String(event.data))
|
||||
if (message.id !== 1) return
|
||||
clearTimeout(timer)
|
||||
socket.close()
|
||||
resolve(message.result?.result?.value)
|
||||
}
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timer)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!reference.json
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"name": "Reference",
|
||||
"readonly": true,
|
||||
"enabled": true,
|
||||
"disabledGroups": {},
|
||||
"slots": {
|
||||
"add-row": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-600",
|
||||
"alpha": 5
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-800",
|
||||
"alpha": 10
|
||||
}
|
||||
},
|
||||
"add-gutter": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-600",
|
||||
"alpha": 5
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-800",
|
||||
"alpha": 10
|
||||
}
|
||||
},
|
||||
"add-inline": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-600",
|
||||
"alpha": 20
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-600",
|
||||
"alpha": 40
|
||||
}
|
||||
},
|
||||
"add-bar": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-900",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-400",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"add-number": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-900",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-400",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"del-row": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 5
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 10
|
||||
}
|
||||
},
|
||||
"del-gutter": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 5
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 10
|
||||
}
|
||||
},
|
||||
"del-inline": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 20
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-600",
|
||||
"alpha": 40
|
||||
}
|
||||
},
|
||||
"del-bar": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-800",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-500",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"del-number": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-800",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-500",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-faint",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-faint",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"fold-text": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-faint",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-faint",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"add-inline-text": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-green-1200",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-base",
|
||||
"alpha": 100
|
||||
}
|
||||
},
|
||||
"del-inline-text": {
|
||||
"light": {
|
||||
"kind": "token",
|
||||
"token": "v2-red-1200",
|
||||
"alpha": 100
|
||||
},
|
||||
"dark": {
|
||||
"kind": "token",
|
||||
"token": "v2-text-text-base",
|
||||
"alpha": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,74 +422,57 @@ const AnthropicUsage = Schema.StructWithRest(
|
||||
)
|
||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||
|
||||
const AnthropicStreamBlock = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// redacted_thinking blocks arrive whole in content_block_start with the
|
||||
// encrypted payload in `data`; there is no streaming delta sequence.
|
||||
data: Schema.optional(Schema.String),
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
// *_tool_result blocks arrive whole as content_block_start (no streaming
|
||||
// delta) with the structured payload in `content` and the originating
|
||||
// server_tool_use id in `tool_use_id`.
|
||||
tool_use_id: Schema.optional(Schema.String),
|
||||
content: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
const AnthropicStreamBlock = Schema.Struct({
|
||||
type: Schema.String,
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// redacted_thinking blocks arrive whole in content_block_start with the
|
||||
// encrypted payload in `data`; there is no streaming delta sequence.
|
||||
data: Schema.optional(Schema.String),
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
// *_tool_result blocks arrive whole as content_block_start (no streaming
|
||||
// delta) with the structured payload in `content` and the originating
|
||||
// server_tool_use id in `tool_use_id`.
|
||||
tool_use_id: Schema.optional(Schema.String),
|
||||
content: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
|
||||
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
|
||||
|
||||
const AnthropicStreamDelta = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
type: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
partial_json: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
stop_reason: optionalNull(Schema.String),
|
||||
stop_sequence: optionalNull(Schema.String),
|
||||
stop_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
|
||||
[JsonObject],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
const AnthropicStreamDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
type: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
partial_json: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
stop_reason: optionalNull(Schema.String),
|
||||
stop_sequence: optionalNull(Schema.String),
|
||||
stop_details: optionalNull(
|
||||
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
|
||||
),
|
||||
})
|
||||
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
|
||||
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
|
||||
|
||||
const AnthropicEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
index: Schema.optional(Schema.Number),
|
||||
message: Schema.optional(
|
||||
Schema.StructWithRest(Schema.Struct({ usage: Schema.optional(AnthropicUsage) }), [JsonObject]),
|
||||
),
|
||||
content_block: Schema.optional(Schema.Unknown),
|
||||
delta: Schema.optional(Schema.Unknown),
|
||||
usage: Schema.optional(AnthropicUsage),
|
||||
// `type` and `message` are both required per Anthropic's spec, but
|
||||
// OpenAI-compatible proxies and gateway translations occasionally drop one
|
||||
// or the other; mark them optional so a partial payload still parses and
|
||||
// the parser can fall back to whichever field is populated.
|
||||
error: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
|
||||
[JsonObject],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
const AnthropicEvent = Schema.Struct({
|
||||
type: Schema.String,
|
||||
index: Schema.optional(Schema.Number),
|
||||
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
|
||||
content_block: Schema.optional(Schema.Unknown),
|
||||
delta: Schema.optional(Schema.Unknown),
|
||||
usage: Schema.optional(AnthropicUsage),
|
||||
// `type` and `message` are both required per Anthropic's spec, but
|
||||
// OpenAI-compatible proxies and gateway translations occasionally drop one
|
||||
// or the other; mark them optional so a partial payload still parses and
|
||||
// the parser can fall back to whichever field is populated.
|
||||
error: Schema.optional(
|
||||
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
|
||||
@@ -301,26 +301,18 @@ export const OpenResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
|
||||
|
||||
export const OpenResponsesUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
cache_write_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
[JsonObject],
|
||||
),
|
||||
),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) }), [JsonObject]),
|
||||
),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
export const OpenResponsesUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
cache_write_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
})
|
||||
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
|
||||
|
||||
// The spec requires `id` on every output item, but some gateways drop it from
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Script } from "@opencode/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "../src/plugin-runtime"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
@@ -79,31 +78,6 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}))
|
||||
},
|
||||
}
|
||||
const pluginRuntimeEntries = discoverPluginRuntimeSpecifiers()
|
||||
const pluginRuntimeTemplate = await Bun.file("./src/plugin-runtime.ts").text()
|
||||
const pluginRuntimeMarker =
|
||||
"const prebundledModules: Readonly<Record<string, RuntimeModuleLoader>> | undefined = undefined"
|
||||
if (!pluginRuntimeTemplate.includes(pluginRuntimeMarker)) {
|
||||
throw new Error("Missing prebundledModules marker in packages/cli/src/plugin-runtime.ts")
|
||||
}
|
||||
const pluginRuntimeSource = pluginRuntimeTemplate.replace(
|
||||
pluginRuntimeMarker,
|
||||
`const prebundledModules: Readonly<Record<string, RuntimeModuleLoader>> | undefined = {\n${[...pluginRuntimeEntries.keys()].map((specifier) => ` ${JSON.stringify(specifier)}: ${pluginRuntimeLoaderCode(specifier, pluginRuntimeEntries)},`).join("\n")}\n}`,
|
||||
)
|
||||
const pluginRuntimePlugin: BunPlugin = {
|
||||
name: "opencode-plugin-runtime",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /cli[/\\]src[/\\]plugin-runtime\.ts$/ }, () => ({
|
||||
contents: pluginRuntimeSource,
|
||||
loader: "ts",
|
||||
}))
|
||||
build.onLoad({ filter: /[/\\]internal[/\\]httpApi(?:Scalar|Swagger)\.js$/ }, () => ({
|
||||
contents:
|
||||
'export const css = ""; export const javascript = \'document.body.textContent = "Scalar/Swagger UI assets are not bundled in OpenCode"\'',
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
@@ -150,14 +124,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [
|
||||
appAssetsPlugin,
|
||||
solidPlugin,
|
||||
parcelWatcherPlugin,
|
||||
opencodePtyPlugin,
|
||||
pluginRuntimePlugin,
|
||||
simulationGraphPlugin,
|
||||
],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { ensurePluginRuntime } from "./plugin-runtime"
|
||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { getErrorReported } from "effect/Runtime"
|
||||
@@ -18,8 +17,6 @@ import { EffectFlock } from "@opencode/util/effect-flock"
|
||||
import { Heap } from "./heap"
|
||||
import { CpuProfile } from "./cpu-profile"
|
||||
|
||||
ensurePluginRuntime()
|
||||
|
||||
if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
|
||||
const { askpass } = await import("./ssh-askpass")
|
||||
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { existsSync, realpathSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
|
||||
type RuntimeModuleLoader = () => Record<string, unknown> | Promise<Record<string, unknown>>
|
||||
|
||||
const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
|
||||
const runtimePackages = ["effect", "@opencode/plugin"] as const
|
||||
const foreignPkgSuffix = String.raw`(?:node_modules[/\\](@opencode[/\\]plugin|effect)|(@opencode[/\\]plugin|effect)@[^/\\]+@@@\d+)`
|
||||
const foreignPkgPattern = new RegExp(String.raw`^(.*[/\\]${foreignPkgSuffix})[/\\](.+)$`)
|
||||
const prebundledModules: Readonly<Record<string, RuntimeModuleLoader>> | undefined = undefined
|
||||
|
||||
type GlobalState = typeof globalThis & {
|
||||
[runtimeModulesKey]?: Readonly<Record<string, RuntimeModuleLoader>>
|
||||
}
|
||||
|
||||
export function discoverPluginRuntimeSpecifiers(
|
||||
from = import.meta.dir,
|
||||
packages: readonly string[] = runtimePackages,
|
||||
): ReadonlyMap<string, string> {
|
||||
const entries = new Map<string, string>()
|
||||
for (const pkgName of packages) {
|
||||
const realDir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, from))
|
||||
const loadDir = findNodeModulesDir(pkgName, from, realDir)
|
||||
const toLoadPath = (resolved: string) =>
|
||||
loadDir === realDir ? resolved : path.join(loadDir, path.relative(realDir, resolved))
|
||||
const rootEntry = Bun.resolveSync(pkgName, from)
|
||||
const relParts = path.relative(realDir, rootEntry).replaceAll("\\", "/").split("/")
|
||||
const scanDir = relParts.length > 1 ? path.join(realDir, relParts[0]) : realDir
|
||||
const ext = path.extname(rootEntry) || ".js"
|
||||
entries.set(pkgName, toLoadPath(rootEntry))
|
||||
for (const file of new Bun.Glob(`**/*${ext}`).scanSync({ cwd: scanDir })) {
|
||||
const normalized = file.replaceAll("\\", "/")
|
||||
if (normalized.startsWith("internal/") || normalized.includes("/internal/") || normalized.startsWith("source.")) {
|
||||
continue
|
||||
}
|
||||
for (const specifier of specifierCandidates(pkgName, normalized)) {
|
||||
if (entries.has(specifier)) continue
|
||||
try {
|
||||
entries.set(specifier, toLoadPath(Bun.resolveSync(specifier, from)))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function pluginRuntimeLoaderCode(specifier: string, entries: ReadonlyMap<string, string>) {
|
||||
if (specifier.startsWith("effect/")) {
|
||||
const slash = specifier.lastIndexOf("/")
|
||||
const parent = specifier.slice(0, slash)
|
||||
const member = specifier.slice(slash + 1)
|
||||
const parentResolved = entries.get(parent)
|
||||
const resolved = entries.get(specifier)
|
||||
if (
|
||||
member !== "index" &&
|
||||
parentResolved &&
|
||||
resolved &&
|
||||
(require(parentResolved) as Record<string, unknown>)[member] === require(resolved)
|
||||
) {
|
||||
return `() => require(${JSON.stringify(parent)})[${JSON.stringify(member)}]`
|
||||
}
|
||||
}
|
||||
return `() => require(${JSON.stringify(specifier)})`
|
||||
}
|
||||
|
||||
export function ensurePluginRuntime() {
|
||||
if (typeof Bun === "undefined") return {}
|
||||
const state = globalThis as GlobalState
|
||||
if (state[runtimeModulesKey]) return state[runtimeModulesKey]
|
||||
const modules =
|
||||
prebundledModules ??
|
||||
(() => {
|
||||
const entries = discoverPluginRuntimeSpecifiers()
|
||||
const effectEntry = entries.get("effect")
|
||||
if (effectEntry) require(effectEntry)
|
||||
return Object.fromEntries(
|
||||
[...entries.entries()].map(([specifier, resolved]) => [specifier, createLoader(resolved)]),
|
||||
)
|
||||
})()
|
||||
state[runtimeModulesKey] = modules
|
||||
const hostPluginDir = prebundledModules
|
||||
? undefined
|
||||
: path.dirname(Bun.resolveSync("@opencode/plugin/package.json", import.meta.dir))
|
||||
Bun.plugin({
|
||||
name: "opencode-plugin-runtime",
|
||||
setup(build) {
|
||||
for (const [specifier, load] of Object.entries(modules)) {
|
||||
build.module(specifier, () => {
|
||||
const exports = load()
|
||||
return exports instanceof Promise
|
||||
? exports.then((value) => ({ exports: value, loader: "object" as const }))
|
||||
: { exports, loader: "object" as const }
|
||||
})
|
||||
}
|
||||
// Temporary until OpenTUI preserves host specifiers (anomalyco/opentui#1569).
|
||||
build.onResolve({ filter: /^(?:file:\/\/|\/|[A-Za-z]:[/\\])/ }, (args) => {
|
||||
if (!args.importer || args.importer === import.meta.path) return undefined
|
||||
const matched = resolveRewrittenHostSpecifier(args.path, modules, hostPluginDir)
|
||||
return matched ? { path: matched } : undefined
|
||||
})
|
||||
build.onLoad({ filter: createForeignPackageFilter() }, (args) => {
|
||||
const match = args.path.match(foreignPkgPattern)
|
||||
const target = match ? `${match[2] ?? match[3]}/${match[4]}`.replaceAll("\\", "/") : args.path
|
||||
throw new Error(
|
||||
`Cannot load "${target}" from plugin node_modules: "${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.`,
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
return modules
|
||||
}
|
||||
|
||||
export function createLoader(resolved: string): RuntimeModuleLoader {
|
||||
let cached: Record<string, unknown> | undefined
|
||||
let pending: Promise<Record<string, unknown>> | undefined
|
||||
return () => {
|
||||
if (cached) return cached
|
||||
if (pending) return pending
|
||||
try {
|
||||
return (cached = require(resolved) as Record<string, unknown>)
|
||||
} catch {
|
||||
return (pending = import(pathToFileURL(resolved).href).then(
|
||||
(mod: Record<string, unknown>) => (cached = mod),
|
||||
(error) => {
|
||||
pending = undefined
|
||||
throw error
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createForeignPackageFilter(rootsInput?: Iterable<string>) {
|
||||
const suffix = String.raw`[/\\]${foreignPkgSuffix}[/\\].*\.[cm]?[jt]sx?(?:[?#].*)?$`
|
||||
if (prebundledModules && !rootsInput) return new RegExp(suffix)
|
||||
const roots = new Set<string>(
|
||||
rootsInput ??
|
||||
runtimePackages.flatMap((pkgName) => {
|
||||
const dir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, import.meta.dir))
|
||||
return [dir, findNodeModulesDir(pkgName, import.meta.dir, dir)]
|
||||
}),
|
||||
)
|
||||
const escaped = [...roots]
|
||||
.map((value) =>
|
||||
value
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/(?:\\\/|\\\\|\/)+/g, "[/\\\\]"),
|
||||
)
|
||||
.join("|")
|
||||
return new RegExp(`^(?!(?:${escaped})[/\\\\]).*${suffix}`)
|
||||
}
|
||||
|
||||
function findNodeModulesDir(pkgName: string, from: string, realDir: string) {
|
||||
if (/[/\\]node_modules[/\\]/.test(realDir)) return realDir
|
||||
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", pkgName)
|
||||
if (existsSync(candidate) && realpathSync(candidate) === realDir) return candidate
|
||||
if (path.dirname(dir) === dir) return realDir
|
||||
}
|
||||
}
|
||||
|
||||
function specifierCandidates(pkgName: string, subpath: string) {
|
||||
const base = subpath
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^(?:dist(?:\/(?:esm|cjs))?|src)\//, "")
|
||||
.replace(/\.[cm]?[jt]sx?(?:[?#].*)?$/, "")
|
||||
if (base === "index") return [pkgName]
|
||||
return base.endsWith("/index")
|
||||
? [`${pkgName}/${base.slice(0, -"/index".length)}`, `${pkgName}/${base}`, pkgName]
|
||||
: [`${pkgName}/${base}`, pkgName]
|
||||
}
|
||||
|
||||
function resolveRewrittenHostSpecifier(
|
||||
specifier: string,
|
||||
modules: Readonly<Record<string, unknown>>,
|
||||
hostPluginDir?: string,
|
||||
) {
|
||||
const targetPath = (() => {
|
||||
try {
|
||||
return specifier.startsWith("file://") ? fileURLToPath(specifier) : specifier.replace(/[?#].*$/, "")
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!targetPath) return undefined
|
||||
const match = targetPath.match(foreignPkgPattern)
|
||||
const rel = !match && hostPluginDir ? path.relative(hostPluginDir, targetPath) : undefined
|
||||
if ((!match && (!rel || rel.startsWith("..") || path.isAbsolute(rel))) || !existsSync(targetPath)) return undefined
|
||||
const pkgDir = match ? match[1] : hostPluginDir!
|
||||
const pkgName = match ? (match[2] ?? match[3]).replaceAll("\\", "/") : "@opencode/plugin"
|
||||
const targetReal = realpathSync(targetPath)
|
||||
return specifierCandidates(pkgName, match ? match[4] : rel!).find((candidate) => {
|
||||
if (!(candidate in modules)) return false
|
||||
try {
|
||||
return Bun.resolveSync(candidate, pkgDir) === targetReal
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
|
||||
import { contenderPool, spawnServiceContender } from "../service-contender.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
@@ -48,9 +53,12 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||
const timing = ensureTiming(options)
|
||||
const pool = contenderPool(timing)
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let failure: Error | undefined
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
||||
Effect.sync(() => {
|
||||
if (announced) return
|
||||
@@ -82,13 +90,19 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
yield* terminate(info, options, timing)
|
||||
pool.evict(info.pid)
|
||||
pool.recruitNow()
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
pool.serviceAnswered()
|
||||
spawnDelay = timing.spawnDelay
|
||||
const versionMatches = matchesVersion(service.version, options)
|
||||
const compatible = service.compatible && versionMatches
|
||||
if (!service.compatible && versionMatches)
|
||||
@@ -111,15 +125,30 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).pipe(Effect.ignore)
|
||||
pool.evict(service.info.pid)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === service.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
}
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
|
||||
const failed = pool.reap()
|
||||
if (failed !== undefined) return yield* Effect.fail(failed)
|
||||
if (pool.shouldRecruit(info !== undefined)) {
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
failure ??= finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
|
||||
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
|
||||
// survivors finish without recruiting replacements that could hide the error indefinitely.
|
||||
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
yield* announce("missing")
|
||||
pool.add(yield* spawnContender)
|
||||
contenders.add(yield* spawnContender)
|
||||
lastSpawn = Date.now()
|
||||
}
|
||||
return Option.none<LocalService>()
|
||||
}).pipe(
|
||||
@@ -129,10 +158,10 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
// like the Promise variant rather than by attempt count.
|
||||
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => pool.releaseAll())),
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
if (Option.isNone(found))
|
||||
return yield* Effect.fail(pool.failure() ?? new Error("Timed out waiting for the background service to start"))
|
||||
return yield* Effect.fail(failure ?? new Error("Timed out waiting for the background service to start"))
|
||||
return found.value.endpoint
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import { contenderPool, spawnServiceContender } from "../service-contender.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
@@ -28,9 +33,12 @@ export async function discover(options: DiscoverOptions = {}) {
|
||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const timing = ensureTiming(options)
|
||||
const deadline = Date.now() + timing.promiseTimeout
|
||||
const pool = contenderPool(timing)
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let failure: Error | undefined
|
||||
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||
if (announced) return
|
||||
@@ -49,8 +57,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline)
|
||||
throw pool.failure() ?? new Error("Timed out waiting for the background service to start")
|
||||
if (Date.now() >= deadline) throw failure ?? new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, timing.requestTimeout)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
@@ -62,14 +69,20 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
await PtyHandoff.clear(options.file ?? fallback())
|
||||
await terminate(registration.info, options, timing)
|
||||
pool.evict(registration.info.pid)
|
||||
pool.recruitNow()
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === registration.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
pool.serviceAnswered()
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
const versionMatches = matchesVersion(service.version, options)
|
||||
const compatible = service.compatible && versionMatches
|
||||
@@ -90,20 +103,36 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).catch(() => undefined)
|
||||
pool.evict(service.info.pid)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === service.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
const failed = pool.reap()
|
||||
if (failed !== undefined) throw failed
|
||||
if (pool.shouldRecruit(registration.info !== undefined)) {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
failure ??= finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) throw failure
|
||||
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
|
||||
// survivors finish without recruiting replacements that could hide the error indefinitely.
|
||||
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
pool.add(await spawnContender())
|
||||
contenders.add(await spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
}
|
||||
}
|
||||
await delay(timing.pollInterval)
|
||||
}
|
||||
} finally {
|
||||
pool.releaseAll()
|
||||
contenders.forEach((contender) => contender.release())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import type { EnsureTiming } from "./service-timing.js"
|
||||
|
||||
export type ServiceContender = {
|
||||
readonly child: ChildProcess
|
||||
@@ -54,70 +53,7 @@ export function spawnServiceContender(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The startup attempts of one `ensure()` call. It keeps at most two attempts alive, remembers
|
||||
* the first startup failure, and backs off when attempts exit cleanly because another one won.
|
||||
*/
|
||||
export function contenderPool(timing: EnsureTiming) {
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let failure: Error | undefined
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let lastSpawn = 0
|
||||
return {
|
||||
/** The first startup failure seen since the last eviction. */
|
||||
failure: () => failure,
|
||||
/** A registered service answered, so the next attempt waits the base delay again. */
|
||||
serviceAnswered() {
|
||||
spawnDelay = timing.spawnDelay
|
||||
},
|
||||
/**
|
||||
* The owner `pid` was replaced. Drop its attempt and any finished ones, forget their
|
||||
* failure, and restart the spawn clock.
|
||||
*/
|
||||
evict(pid: number) {
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
},
|
||||
/** Let the next attempt start without waiting a spawn delay. */
|
||||
recruitNow() {
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
},
|
||||
/** Collect finished attempts. Returns the startup failure once no attempt is left alive. */
|
||||
reap() {
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
failure ??= finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0))
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
return contenders.size === 0 ? failure : undefined
|
||||
},
|
||||
/**
|
||||
* Whether to start another attempt now. A registration that has not answered yet gets one
|
||||
* spawn delay before an attempt competes with it.
|
||||
*/
|
||||
shouldRecruit(registered: boolean) {
|
||||
if (lastSpawn === 0 && registered) lastSpawn = Date.now()
|
||||
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
|
||||
// survivors finish without recruiting replacements that could hide the error indefinitely.
|
||||
return failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay
|
||||
},
|
||||
add(contender: ServiceContender) {
|
||||
contenders.add(contender)
|
||||
lastSpawn = Date.now()
|
||||
},
|
||||
releaseAll() {
|
||||
contenders.forEach((contender) => contender.release())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function contenderFailure(contender: ServiceContender) {
|
||||
export function contenderFailure(contender: ServiceContender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import {
|
||||
contenderFinished,
|
||||
contenderPool,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../src/service-contender"
|
||||
import { defaultEnsureTiming } from "../src/service-timing"
|
||||
|
||||
const spawned: ServiceContender[] = []
|
||||
afterEach(() => {
|
||||
spawned.splice(0).forEach((contender) => {
|
||||
contender.release()
|
||||
contender.child.kill("SIGKILL")
|
||||
})
|
||||
})
|
||||
|
||||
test("remembers the first startup failure and reports it once no attempt is left", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 0 })
|
||||
const survivor = running()
|
||||
const failed = failing("first failure")
|
||||
pool.add(survivor)
|
||||
pool.add(failed)
|
||||
await finished(failed)
|
||||
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
expect(pool.failure()?.message).toContain("first failure")
|
||||
expect(pool.shouldRecruit(false)).toBe(false)
|
||||
|
||||
survivor.child.kill("SIGKILL")
|
||||
await finished(survivor)
|
||||
expect(pool.reap()?.message).toContain("first failure")
|
||||
})
|
||||
|
||||
test("evicting a replaced owner forgets its failure and restarts the spawn clock", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 60_000 })
|
||||
const owner = running()
|
||||
const failed = failing("old failure")
|
||||
pool.add(owner)
|
||||
pool.add(failed)
|
||||
await finished(failed)
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
expect(pool.failure()).toBeDefined()
|
||||
|
||||
pool.evict(owner.child.pid!)
|
||||
|
||||
expect(pool.failure()).toBeUndefined()
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
expect(pool.shouldRecruit(false)).toBe(true)
|
||||
expect(owner.child.exitCode).toBeNull()
|
||||
})
|
||||
|
||||
test("an unanswered registration gets one spawn delay before an attempt competes", () => {
|
||||
const timing = { ...defaultEnsureTiming, spawnDelay: 60_000 }
|
||||
expect(contenderPool(timing).shouldRecruit(false)).toBe(true)
|
||||
|
||||
const registered = contenderPool(timing)
|
||||
expect(registered.shouldRecruit(true)).toBe(false)
|
||||
registered.recruitNow()
|
||||
expect(registered.shouldRecruit(true)).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps at most two attempts alive", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 0 })
|
||||
const exiting = clean()
|
||||
pool.add(running())
|
||||
pool.add(exiting)
|
||||
expect(pool.shouldRecruit(false)).toBe(false)
|
||||
|
||||
await finished(exiting)
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
expect(pool.shouldRecruit(false)).toBe(true)
|
||||
})
|
||||
|
||||
test("clean exits back off up to the maximum spawn delay", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 200, maxSpawnDelay: 300 })
|
||||
const exiting = clean()
|
||||
pool.add(exiting)
|
||||
const start = Date.now()
|
||||
await finished(exiting)
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
|
||||
// The base delay (200 ms) has passed, but the backed-off delay (300 ms) has not, unless a
|
||||
// loaded machine overslept past it.
|
||||
await Bun.sleep(Math.max(0, 250 - (Date.now() - start)))
|
||||
if (Date.now() - start < 290) expect(pool.shouldRecruit(false)).toBe(false)
|
||||
// Doubling would give 400 ms; the cap allows an attempt at 300 ms.
|
||||
await Bun.sleep(Math.max(0, 320 - (Date.now() - start)))
|
||||
expect(pool.shouldRecruit(false)).toBe(true)
|
||||
})
|
||||
|
||||
test("an answering service resets the backoff to the base spawn delay", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 200, maxSpawnDelay: 1_000 })
|
||||
const exiting = clean()
|
||||
pool.add(exiting)
|
||||
const start = Date.now()
|
||||
await finished(exiting)
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
|
||||
pool.serviceAnswered()
|
||||
// Backed off, the next attempt would wait 400 ms; after the reset it waits the base 200 ms.
|
||||
await Bun.sleep(Math.max(0, 250 - (Date.now() - start)))
|
||||
expect(pool.shouldRecruit(false)).toBe(true)
|
||||
})
|
||||
|
||||
function spawn(code: string) {
|
||||
const contender = spawnServiceContender(process.execPath, ["-e", code])
|
||||
spawned.push(contender)
|
||||
return contender
|
||||
}
|
||||
|
||||
function running() {
|
||||
return spawn("setTimeout(() => {}, 60_000)")
|
||||
}
|
||||
|
||||
function failing(message: string) {
|
||||
return spawn(`console.error(${JSON.stringify(message)}); process.exit(1)`)
|
||||
}
|
||||
|
||||
function clean() {
|
||||
return spawn("process.exit(0)")
|
||||
}
|
||||
|
||||
async function finished(contender: ServiceContender) {
|
||||
while (!contenderFinished(contender)) await Bun.sleep(5)
|
||||
}
|
||||
@@ -14,12 +14,7 @@ type Target<ID extends string> = Overlays & {
|
||||
variants?: (Overlays & { id: ID })[]
|
||||
}
|
||||
|
||||
type Context = {
|
||||
readonly providerID: string
|
||||
readonly canonical?: string
|
||||
readonly modelID?: string
|
||||
readonly shape?: "responses" | "completions"
|
||||
}
|
||||
type Context = { readonly providerID: string; readonly canonical?: string; readonly modelID?: string }
|
||||
|
||||
export function rewrite<ID extends string>(
|
||||
target: Target<ID>,
|
||||
@@ -131,13 +126,11 @@ export function native(npm: string, context: Context & { readonly settings?: Pro
|
||||
const host = HOSTS[context.providerID]?.[npm]
|
||||
if (host) return host
|
||||
if (npm === "@ai-sdk/amazon-bedrock/mantle") return mantle(context.modelID)
|
||||
if (npm === "@ai-sdk/azure" && azureChat(context)) return "@opencode/ai/providers/azure/chat"
|
||||
if (npm === "@ai-sdk/azure" && context.settings?.useCompletionUrls === true)
|
||||
return "@opencode/ai/providers/azure/chat"
|
||||
return PACKAGES[npm]
|
||||
}
|
||||
|
||||
const azureChat = (context: Context & { readonly settings?: Provider.Settings }) =>
|
||||
context.shape === "completions" || context.settings?.useCompletionUrls === true
|
||||
|
||||
const mantle = (modelID: string | undefined) => {
|
||||
if (modelID === undefined) return "@opencode/ai/providers/amazon-bedrock/mantle"
|
||||
return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}`
|
||||
@@ -148,7 +141,7 @@ function resolve(specifier: string, context: Context & { readonly settings?: Pro
|
||||
if (Provider.isAISDK(specifier) || npm in PACKAGES || npm in (HOSTS[context.providerID] ?? {}))
|
||||
return native(npm, context)
|
||||
if (npm === "@opencode/ai/providers/amazon-bedrock/mantle") return mantle(context.modelID)
|
||||
if (npm === "@opencode/ai/providers/azure/responses" && azureChat(context))
|
||||
if (npm === "@opencode/ai/providers/azure/responses" && context.settings?.useCompletionUrls === true)
|
||||
return "@opencode/ai/providers/azure/chat"
|
||||
return NATIVE.has(npm) ? npm : undefined
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as ConfigInstructionPlugin from "./instruction.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { sameDirectory } from "@opencode/util/path"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
@@ -134,10 +133,7 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
// `start` keeps the client's spelling while `stop` may come from git, so a Windows drive
|
||||
// letter can differ only in case (`c:\repo` vs `C:\repo`). Compare the way FSUtil.contains
|
||||
// admitted `start` beneath `stop`, or the walk passes `stop` and recurses at the drive root.
|
||||
function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (sameDirectory(start, stop)) return [start]
|
||||
if (start === stop) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "@modelcontextprotocol/client"
|
||||
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
|
||||
import { Cause, Deferred, Effect } from "effect"
|
||||
import type { ServerResponse } from "node:http"
|
||||
import { ConfigMCP } from "@opencode/schema/config/mcp"
|
||||
import { Credential } from "../credential.js"
|
||||
import { OauthCallbackPage } from "../oauth/page.js"
|
||||
@@ -349,7 +348,7 @@ export const authorize = (input: {
|
||||
const client = clientFromCredential(previous)
|
||||
if (client) yield* Effect.promise(() => store.saveClientInformation(client))
|
||||
}
|
||||
const code = yield* Deferred.make<{ code: string; iss: string | undefined; response: ServerResponse }, Error>()
|
||||
const code = yield* Deferred.make<{ code: string; iss: string | undefined }, Error>()
|
||||
const redirect = oauth?.redirect_uri ? new URL(oauth.redirect_uri) : undefined
|
||||
const redirectPath = redirect?.pathname ?? "/callback"
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
@@ -364,7 +363,7 @@ export const authorize = (input: {
|
||||
}
|
||||
const fail = (reason: string, failure: string) => {
|
||||
runFork(Effect.logWarning("mcp oauth callback rejected", { ...fields, reason: failure }))
|
||||
Effect.runSync(Deferred.fail(code, new Error(reason)))
|
||||
Effect.runFork(Deferred.fail(code, new Error(reason)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(reason, { provider: input.name }))
|
||||
@@ -374,11 +373,8 @@ export const authorize = (input: {
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
|
||||
const value = url.searchParams.get("code")
|
||||
if (!value) return fail("Missing authorization code", "missing_code")
|
||||
// The page waits for the token exchange so the browser never reports success for a rejected code.
|
||||
if (
|
||||
!Effect.runSync(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined, response }))
|
||||
)
|
||||
response.writeHead(409).end("OAuth callback already received")
|
||||
Effect.runFork(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined }))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
|
||||
})
|
||||
|
||||
// callback_port, else the port pinned by redirect_uri, else ephemeral; a mismatch strands the browser.
|
||||
@@ -474,52 +470,30 @@ export const authorize = (input: {
|
||||
if (!authorizationUrl)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
|
||||
|
||||
const completed = yield* Deferred.make<Credential.OAuth, Error>()
|
||||
yield* Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) => {
|
||||
const respond = (error?: string) =>
|
||||
Effect.sync(() =>
|
||||
value.response
|
||||
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
|
||||
.end(
|
||||
error
|
||||
? OauthCallbackPage.error(error, { provider: input.name })
|
||||
: OauthCallbackPage.success({ provider: input.name }),
|
||||
),
|
||||
)
|
||||
return Effect.tryPromise({
|
||||
try: () =>
|
||||
auth(oauthProvider, {
|
||||
serverUrl: input.config.url,
|
||||
authorizationCode: value.code,
|
||||
iss: value.iss,
|
||||
scope: oauth?.scope ?? challenge.scope,
|
||||
fetchFn,
|
||||
}),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.flatMap(() => finalize),
|
||||
Effect.tap(() => respond()),
|
||||
Effect.tapError((error) => respond(error.message)),
|
||||
Effect.onInterrupt(() => Effect.sync(() => value.response.destroy())),
|
||||
)
|
||||
}),
|
||||
Effect.onErrorIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) =>
|
||||
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
|
||||
),
|
||||
Effect.annotateLogs(fields),
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Deferred.done(completed, exit)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
return {
|
||||
url: authorizationUrl.toString(),
|
||||
instructions: `Authorize ${input.name} in your browser. This window will close automatically.`,
|
||||
mode: "auto" as const,
|
||||
callback: Deferred.await(completed),
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth(oauthProvider, {
|
||||
serverUrl: input.config.url,
|
||||
authorizationCode: value.code,
|
||||
iss: value.iss,
|
||||
scope: oauth?.scope ?? challenge.scope,
|
||||
fetchFn,
|
||||
}),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(() => finalize),
|
||||
Effect.onError((cause) =>
|
||||
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
|
||||
),
|
||||
Effect.annotateLogs(fields),
|
||||
),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.onError((cause) =>
|
||||
|
||||
@@ -408,9 +408,7 @@ export const layer = Layer.effect(
|
||||
Effect.flatMap((model) =>
|
||||
model && hasPackage(model)
|
||||
? Effect.succeed(model)
|
||||
: Effect.map(models.available(), (models) =>
|
||||
models.find((model) => hasPackage(model) && Model.supportsText(model)),
|
||||
),
|
||||
: Effect.map(models.available(), (models) => models.find(hasPackage)),
|
||||
),
|
||||
)
|
||||
if (!selected) return undefined
|
||||
|
||||
@@ -261,7 +261,7 @@ const layer = Layer.effect(
|
||||
const value = yield* read()
|
||||
const requested = value.data.defaultModel
|
||||
const model = requested && value.byProvider.get(requested.providerID)?.get(requested.modelID)
|
||||
return model?.enabled ? model : value.available.find(supportsText)
|
||||
return model?.enabled ? model : value.available[0]
|
||||
}),
|
||||
small: Effect.fn("Model.small")(function* (providerID) {
|
||||
const value = yield* read()
|
||||
@@ -282,13 +282,6 @@ const layer = Layer.effect(
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Provider.node, Bus.node, Location.node] })
|
||||
|
||||
export function supportsText(model: Pick<Info, "capabilities">) {
|
||||
return (
|
||||
(model.capabilities.input.length === 0 || model.capabilities.input.some((item) => item.startsWith("text"))) &&
|
||||
(model.capabilities.output.length === 0 || model.capabilities.output.some((item) => item.startsWith("text")))
|
||||
)
|
||||
}
|
||||
|
||||
export function compatibility(input: unknown): Compatibility | undefined {
|
||||
if (typeof input === "string") return { reasoningField: input }
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined
|
||||
|
||||
@@ -60,11 +60,7 @@ type SourceModel = {
|
||||
>
|
||||
}
|
||||
readonly status?: CatalogModelStatus
|
||||
readonly provider?: {
|
||||
readonly npm?: string
|
||||
readonly api?: string
|
||||
readonly shape?: "responses" | "completions"
|
||||
}
|
||||
readonly provider?: { readonly npm?: string; readonly api?: string }
|
||||
}
|
||||
|
||||
type SourceProvider = {
|
||||
@@ -84,13 +80,7 @@ export type Snapshot = {
|
||||
|
||||
function nativePackage(provider: SourceProvider, model?: SourceModel) {
|
||||
const npm = model?.provider?.npm ?? provider.npm
|
||||
return (
|
||||
AISDKNative.native(npm, {
|
||||
providerID: provider.id,
|
||||
modelID: model?.id,
|
||||
shape: model?.provider?.shape,
|
||||
}) ?? Provider.aisdk(npm)
|
||||
)
|
||||
return AISDKNative.native(npm, { providerID: provider.id, modelID: model?.id }) ?? Provider.aisdk(npm)
|
||||
}
|
||||
|
||||
function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
|
||||
@@ -213,12 +203,11 @@ function modelInfo(
|
||||
} = {},
|
||||
): Model.Info {
|
||||
const providerID = Provider.ID.make(provider.id)
|
||||
const resolved = nativePackage(provider, model)
|
||||
const pkg = model.provider?.npm || resolved !== nativePackage(provider) ? resolved : undefined
|
||||
const pkg = model.provider?.npm ? nativePackage(provider, model) : undefined
|
||||
// Per model, so it never merges into a model that overrides to a different package.
|
||||
const settings = {
|
||||
...(model.provider?.api ? { baseURL: model.provider.api } : {}),
|
||||
...(resolved === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}),
|
||||
...(nativePackage(provider, model) === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}),
|
||||
}
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
@@ -7,13 +7,19 @@ import { Permission } from "../permission.js"
|
||||
|
||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
Your strengths:
|
||||
- Rapidly finding files using glob patterns
|
||||
- Searching code and text with powerful regex patterns
|
||||
- Reading and analyzing file contents
|
||||
|
||||
Guidelines:
|
||||
- Your role is EXCLUSIVELY to search and analyze
|
||||
- Parallelize independent tool calls for searches and reads whenever possible
|
||||
- Use Glob for broad file pattern matching
|
||||
- Use Grep for searching file contents with regex
|
||||
- Use Read when you know the specific file path you need to read
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- You MUST NOT create, modify, delete, move, or copy files, including temporary files and reports
|
||||
- Shell commands MUST be read-only. NEVER run commands that write files or change system state
|
||||
- For clear communication, avoid using emojis
|
||||
- Do not create any files, or run bash commands that modify the user's system state in any way
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.`
|
||||
|
||||
@@ -109,7 +115,6 @@ export const Plugin = define({
|
||||
...Permission.merge(
|
||||
[
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
{ action: "glob", resource: "*", effect: "allow" },
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Integration } from "@opencode/schema/integration"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Model } from "../model.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
|
||||
// These catalog entries require inference profiles on Bedrock Runtime.
|
||||
@@ -101,7 +100,6 @@ function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
models: provider.models.filter(
|
||||
(model) =>
|
||||
model.status !== "deprecated" &&
|
||||
Model.supportsText(model) &&
|
||||
!(
|
||||
provider.info.id === Provider.ID.amazonBedrock &&
|
||||
BEDROCK_PROFILE_ONLY_IDS.includes(model.modelID ?? model.id)
|
||||
|
||||
@@ -470,8 +470,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
})
|
||||
|
||||
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
// Scoped so responses whose body is never read (404, errors) are released here instead of by a GC-time abort.
|
||||
return HttpClient.withScope(http)
|
||||
return http
|
||||
.execute(
|
||||
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
@@ -499,7 +498,6 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.scoped,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ export function text(message: SessionMessage.Info | undefined) {
|
||||
message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n") || NO_TEXT
|
||||
.join("") || NO_TEXT
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -210,10 +210,7 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
|
||||
catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }),
|
||||
})
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const name = ShellSelect.name(shell)
|
||||
const result = powershell
|
||||
? ShellScan.scanPowerShell(command)
|
||||
: ShellScan.scan(command, name === "bash" || name === "zsh" ? name : "posix")
|
||||
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
if (result.kind === "opaque")
|
||||
return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`))
|
||||
|
||||
@@ -228,21 +225,15 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
|
||||
if (CWD.has(name)) {
|
||||
output.directories.push(
|
||||
...directoryArgs(
|
||||
powershell
|
||||
? words.flatMap((text): Part[] => {
|
||||
const parameter = /^(-(?:literalpath|path)):(.*)$/i.exec(text)
|
||||
if (parameter)
|
||||
return [
|
||||
{ type: "command_parameter", text: parameter[1] },
|
||||
{ type: "word", text: parameter[2] },
|
||||
]
|
||||
return [{ type: text.startsWith("-") ? "command_parameter" : "word", text }]
|
||||
})
|
||||
: item.rawWords.map((text, index) => ({
|
||||
type: "word",
|
||||
// Only literal quoting resolves to a static directory.
|
||||
text: text.startsWith("$'") || (!/[$`~\\]/.test(text) && /['"]/.test(text)) ? item.words[index] : text,
|
||||
})),
|
||||
words.flatMap((text): Part[] => {
|
||||
const parameter = powershell ? /^(-(?:literalpath|path)):(.*)$/i.exec(text) : undefined
|
||||
if (parameter)
|
||||
return [
|
||||
{ type: "command_parameter", text: parameter[1] },
|
||||
{ type: "word", text: parameter[2] },
|
||||
]
|
||||
return [{ type: powershell && text.startsWith("-") ? "command_parameter" : "word", text }]
|
||||
}),
|
||||
powershell,
|
||||
cwd,
|
||||
shell,
|
||||
|
||||
@@ -575,7 +575,6 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
|
||||
"@opencode/ai/providers/zai-coding-plan/responses": openaiResponses,
|
||||
|
||||
"@opencode/ai/providers/openai-compatible": openaiCompatible,
|
||||
"@opencode/ai/providers/azure/chat": openaiChat,
|
||||
"@opencode/ai/providers/google-vertex/chat": openaiChat,
|
||||
"@opencode/ai/providers/alibaba/chat": alibabaChat,
|
||||
"@opencode/ai/providers/baseten": basetenChat,
|
||||
|
||||
@@ -203,8 +203,6 @@ describe("Agent", () => {
|
||||
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
|
||||
const explore = yield* agent.get(Agent.ID.make("explore"))
|
||||
expect(Permission.evaluate("shell", "git log -5", explore?.permissions ?? []).effect).toBe("allow")
|
||||
expect(Permission.evaluate("edit", "src/index.ts", explore?.permissions ?? []).effect).toBe("deny")
|
||||
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
|
||||
|
||||
@@ -224,9 +224,6 @@ describe("AISDKNative", () => {
|
||||
const chat = map("@ai-sdk/azure", { ...settings, useCompletionUrls: true }, "custom-deployment")
|
||||
expect(chat?.package).toBe("@opencode/ai/providers/azure/chat")
|
||||
expect(chat?.settings).not.toHaveProperty("useCompletionUrls")
|
||||
expect(AISDKNative.native("@ai-sdk/azure", { providerID: "azure", shape: "completions" })).toBe(
|
||||
"@opencode/ai/providers/azure/chat",
|
||||
)
|
||||
})
|
||||
|
||||
test("maps Bedrock provider and request options", () => {
|
||||
|
||||
@@ -553,16 +553,6 @@ describe("Provider and Model", () => {
|
||||
editor.models.update(providerID, Model.ID.make("new"), (model) => {
|
||||
model.time.released = 2000
|
||||
})
|
||||
editor.models.update(providerID, Model.ID.make("newest-video"), (model) => {
|
||||
model.capabilities.input = ["text", "image"]
|
||||
model.capabilities.output = ["video"]
|
||||
model.time.released = 3000
|
||||
})
|
||||
editor.models.update(providerID, Model.ID.make("newest-stt"), (model) => {
|
||||
model.capabilities.input = ["audio"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.time.released = 4000
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* models.default())?.id).toMatch("new")
|
||||
|
||||
@@ -364,53 +364,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
// A client CWD can spell the drive `c:` while git reports the project root as `C:`.
|
||||
;(process.platform === "win32" ? it.live : it.live.skip)(
|
||||
"stops at the project root when the location drive letter differs in case",
|
||||
() =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(tmp.path, "home")
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const directory = project[0].toLowerCase() + project.slice(1)
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(project, "AGENTS.md"), "project"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "AGENTS.md"), "outside"))
|
||||
const discovery = yield* start()
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(global, "AGENTS.md"), type: "file" },
|
||||
{ path: path.join(directory, "AGENTS.md"), type: "file" },
|
||||
])
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
||||
`Instructions from: ${path.join(directory, "AGENTS.md")}\nproject`,
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
home,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("isolates source failure without failing activation", () => {
|
||||
const failingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
|
||||
@@ -145,7 +145,6 @@ describe("MCP OAuth", () => {
|
||||
redirect.searchParams.set("code", "accepted")
|
||||
redirect.searchParams.set("state", state)
|
||||
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(200)
|
||||
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(409)
|
||||
return yield* authorization.callback
|
||||
}),
|
||||
),
|
||||
@@ -160,40 +159,6 @@ describe("MCP OAuth", () => {
|
||||
expect(tokenRequests[0]?.get("resource")).toBe(server.url.href)
|
||||
})
|
||||
|
||||
test("reports a rejected token exchange on the callback page", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
if (request.method !== "POST" || new URL(request.url).pathname !== "/token")
|
||||
return new Response(null, { status: 404 })
|
||||
return Response.json(
|
||||
{ error: "invalid_client", error_description: "bad_client_secret" },
|
||||
{ status: 400 },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const { authorization, url: authorizationUrl } = yield* start(server, { client_id: "client" })
|
||||
const redirect = new URL(authorizationUrl.searchParams.get("redirect_uri") ?? "")
|
||||
redirect.searchParams.set("code", "accepted")
|
||||
redirect.searchParams.set("state", authorizationUrl.searchParams.get("state") ?? "")
|
||||
const response = yield* Effect.promise(() => fetch(redirect))
|
||||
const page = yield* Effect.promise(() => response.text())
|
||||
const exit = yield* Effect.exit(authorization.callback)
|
||||
return { status: response.status, page, exit }
|
||||
}),
|
||||
),
|
||||
).finally(() => server.stop(true))
|
||||
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.page).toContain("Authorization failed")
|
||||
expect(result.page).toContain("bad_client_secret")
|
||||
expect(result.exit._tag).toBe("Failure")
|
||||
})
|
||||
|
||||
test("refreshes tokens loaded from a persisted credential", async () => {
|
||||
const tokenRequests: URLSearchParams[] = []
|
||||
const server = Bun.serve({
|
||||
|
||||
@@ -255,23 +255,6 @@ describe("ModelsDev Service", () => {
|
||||
api: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
models: {},
|
||||
},
|
||||
azure: {
|
||||
id: "azure",
|
||||
name: "Azure",
|
||||
env: ["AZURE_RESOURCE_NAME", "AZURE_API_KEY"],
|
||||
npm: "@ai-sdk/azure",
|
||||
models: {
|
||||
"gpt-5": {
|
||||
...fixture.acme.models["acme-1"],
|
||||
id: "gpt-5",
|
||||
},
|
||||
"custom-chat": {
|
||||
...fixture.acme.models["acme-1"],
|
||||
id: "custom-chat",
|
||||
provider: { shape: "completions" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const state = yield* Ref.make(initialState)
|
||||
const result = yield* provided(
|
||||
@@ -283,11 +266,6 @@ describe("ModelsDev Service", () => {
|
||||
expect(result[0]?.models[0]?.package).toBe("@opencode/ai/providers/openai")
|
||||
expect(result[1]?.info.package).toBe("@opencode/ai/providers/cloudflare-workers-ai")
|
||||
expect(result[1]?.info.settings).toBeUndefined()
|
||||
expect(result[2]?.info.package).toBe("@opencode/ai/providers/azure/responses")
|
||||
expect(result[2]?.models.find((model) => model.id === "gpt-5")?.package).toBeUndefined()
|
||||
expect(result[2]?.models.find((model) => model.id === "custom-chat")?.package).toBe(
|
||||
"@opencode/ai/providers/azure/chat",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -624,7 +624,7 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits deprecated and non-text model definitions", () =>
|
||||
it.effect("omits deprecated model definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const providers = yield* Provider.Service
|
||||
@@ -632,13 +632,11 @@ describe("ModelsDevPlugin", () => {
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const activeID = Model.ID.make("current")
|
||||
const deprecatedID = Model.ID.make("legacy")
|
||||
const videoID = Model.ID.make("video-gen")
|
||||
const sttID = Model.ID.make("transcribe")
|
||||
const model = {
|
||||
modelID: activeID,
|
||||
providerID,
|
||||
name: "Current",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
capabilities: { tools: true, input: [], output: [] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
@@ -664,21 +662,6 @@ describe("ModelsDevPlugin", () => {
|
||||
name: "Legacy",
|
||||
status: "deprecated" as const,
|
||||
},
|
||||
{
|
||||
id: videoID,
|
||||
...model,
|
||||
modelID: videoID,
|
||||
name: "Video Gen",
|
||||
capabilities: { tools: false, input: ["text", "image"], output: ["video"] },
|
||||
limit: { context: 1_024, output: 0 },
|
||||
},
|
||||
{
|
||||
id: sttID,
|
||||
...model,
|
||||
modelID: sttID,
|
||||
name: "Transcribe",
|
||||
capabilities: { tools: false, input: ["audio"], output: ["text"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]
|
||||
@@ -701,8 +684,6 @@ describe("ModelsDevPlugin", () => {
|
||||
yield* activate(providers)
|
||||
expect(yield* modelState.get(providerID, activeID)).toBeDefined()
|
||||
expect(yield* modelState.get(providerID, deprecatedID)).toBeUndefined()
|
||||
expect(yield* modelState.get(providerID, videoID)).toBeUndefined()
|
||||
expect(yield* modelState.get(providerID, sttID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
import { expect } from "bun:test"
|
||||
import { cp } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Brand, Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginModule } from "@opencode/core/plugin/module"
|
||||
import { Rpc } from "@opencode/core/rpc"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { execute } from "@opencode/core/tool/runtime"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import {
|
||||
createForeignPackageFilter,
|
||||
createLoader,
|
||||
discoverPluginRuntimeSpecifiers,
|
||||
ensurePluginRuntime,
|
||||
pluginRuntimeLoaderCode,
|
||||
} from "../../../cli/src/plugin-runtime"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
ensurePluginRuntime()
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
PluginTestLayer,
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
|
||||
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
@@ -109,294 +90,3 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads plugins and their transitive dependencies against the host's Effect and @opencode/plugin instances", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const pluginDir = path.join(directory.path, "plugin")
|
||||
const pluginEffectDir = path.join(pluginDir, "node_modules/effect")
|
||||
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await cp(path.join(hostEffectDir, "dist"), path.join(pluginEffectDir, "dist"), {
|
||||
recursive: true,
|
||||
filter: (src) => !src.endsWith(".d.ts") && !src.endsWith(".map") && !/httpApi(?:Scalar|Swagger)\.js$/.test(src),
|
||||
})
|
||||
const pkg = { ...(await Bun.file(path.join(hostEffectDir, "package.json")).json()), version: "4.0.0-rc.111" }
|
||||
|
||||
// Sabotage the plugin's own Effect copy with the version-skew failure modes so loading it would crash:
|
||||
// 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase)
|
||||
// 2. Effect.runPromise calling fiber.succeedWith on a host fiber
|
||||
// 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels
|
||||
const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js")
|
||||
const originalInternalEffect = await Bun.file(internalEffectPath).text()
|
||||
const logPattern =
|
||||
/const logLevel = level \?\? fiber\.(?:currentLogLevel|cache\.logLevel);\r?\n\s*if \(isLogLevelGreaterThan\(fiber\.(?:minimumLogLevel|cache\.minimumLogLevel), logLevel\)\) \{/
|
||||
const runPromisePattern = /const runPromiseExit = runPromiseExitWith\(context\);/
|
||||
expect(logPattern.test(originalInternalEffect)).toBe(true)
|
||||
expect(runPromisePattern.test(originalInternalEffect)).toBe(true)
|
||||
|
||||
await writeFiles(pluginDir, {
|
||||
"node_modules/effect/package.json": JSON.stringify(pkg),
|
||||
"node_modules/effect/dist/internal/effect.js": originalInternalEffect
|
||||
.replace(
|
||||
logPattern,
|
||||
"const logLevel = level ?? fiber.foreignSkew?.logLevel;\n if (isLogLevelGreaterThan(fiber.foreignSkew?.minimumLogLevel, logLevel)) {",
|
||||
)
|
||||
.replace(
|
||||
runPromisePattern,
|
||||
"if (true) return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });\n const runPromiseExit = runPromiseExitWith(context);",
|
||||
),
|
||||
"node_modules/transitive-dep/package.json":
|
||||
'{"name":"transitive-dep","type":"module","exports":{".":"./index.js"}}',
|
||||
"node_modules/transitive-dep/index.js": `import { Effect, Schema } from "effect"
|
||||
import { some } from "effect/Option"
|
||||
export const depToolInput = Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed("from-dep"))),
|
||||
count: Schema.Int,
|
||||
code: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
|
||||
})
|
||||
export const depCaptured = { Effect, Schema, some }`,
|
||||
"node_modules/@opencode/plugin/package.json":
|
||||
'{"name":"@opencode/plugin","type":"module","exports":{"./effect":"./effect.js","./rpc":"./rpc.js"}}',
|
||||
"node_modules/@opencode/plugin/effect.js": "export const Plugin = { define: (p) => p }",
|
||||
"node_modules/@opencode/plugin/rpc.js": "export const Rpc = { define: (d) => d }",
|
||||
"index.ts": `import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Rpc } from "@opencode/plugin/rpc"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { some } from "effect/Option"
|
||||
import { nominal } from "effect/Brand"
|
||||
import { depCaptured, depToolInput } from "transitive-dep"
|
||||
|
||||
export const captured = {
|
||||
plugin: { Effect, Schema, some, nominal },
|
||||
dep: depCaptured,
|
||||
pluginCount: -1,
|
||||
}
|
||||
|
||||
const Contract = Rpc.define({
|
||||
id: "host-effect-rpc",
|
||||
methods: {
|
||||
check: {
|
||||
input: Schema.Struct({
|
||||
count: Schema.Int.pipe(Schema.withDecodingDefault(Effect.succeed(5))),
|
||||
tag: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
|
||||
}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
export default Plugin.define({
|
||||
id: "host-effect-fixture",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.log("setup log from plugin")
|
||||
const listed = yield* Effect.promise(() =>
|
||||
Effect.runPromise(ctx.plugin.list().pipe(Effect.orDie)),
|
||||
)
|
||||
captured.pluginCount = listed.data.length
|
||||
|
||||
yield* ctx.tool.transform((editor) => {
|
||||
editor.add({
|
||||
name: "check_tool",
|
||||
description: "Tool with decoding default from transitive dependency and Int/Trim/isPattern checks",
|
||||
input: depToolInput,
|
||||
output: Schema.Struct({ formatted: Schema.String }),
|
||||
execute: ({ mode, count, code }) =>
|
||||
Effect.log("executing check_tool").pipe(
|
||||
Effect.as({
|
||||
output: { formatted: \`\${mode}:\${code}:\${count}\` },
|
||||
content: \`\${mode}:\${code}:\${count}\`,
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.rpc.register(Contract, {
|
||||
check: ({ count, tag }) =>
|
||||
Effect.log("executing rpc check").pipe(
|
||||
Effect.as({ value: \`\${tag}#\${count}\` }),
|
||||
),
|
||||
}).pipe(Effect.orDie)
|
||||
}),
|
||||
})`,
|
||||
})
|
||||
})
|
||||
|
||||
const modules = yield* PluginModule.make()
|
||||
const plugins = yield* Plugin.Service
|
||||
const tools = yield* Tool.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
|
||||
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
|
||||
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
|
||||
yield* plugins.activate([definition])
|
||||
yield* plugins.awaitActivation
|
||||
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }])
|
||||
|
||||
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
|
||||
expect(imported.captured.plugin.Effect).toBe(Effect)
|
||||
expect(imported.captured.plugin.Schema).toBe(Schema)
|
||||
expect(imported.captured.plugin.some).toBe(Option.some)
|
||||
expect(imported.captured.plugin.nominal).toBe(Brand.nominal)
|
||||
expect(imported.captured.dep.Effect).toBe(Effect)
|
||||
expect(imported.captured.dep.Schema).toBe(Schema)
|
||||
expect(imported.captured.dep.some).toBe(Option.some)
|
||||
expect(imported.captured.pluginCount).toBe(0)
|
||||
|
||||
const checkTool = (yield* tools.list()).find((tool) => tool.id === "check_tool")
|
||||
expect(checkTool).toBeDefined()
|
||||
if (!checkTool) return
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_host_effect"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_host_effect"),
|
||||
id: Tool.CallID.make("call_host_effect"),
|
||||
progress: () => Effect.void,
|
||||
}
|
||||
|
||||
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
|
||||
output: { formatted: "from-dep:v42:3" },
|
||||
content: [{ type: "text", text: "from-dep:v42:3" }],
|
||||
})
|
||||
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("redirects plugin dependencies with a nested Effect 3 installation to the host, allows effect/package.json, and fails loudly on unprovided subpaths", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const pluginDir = path.join(directory.path, "v3-dep-plugin")
|
||||
const badDir = path.join(directory.path, "removed-subpath-plugin")
|
||||
const v3DepDir = path.join(pluginDir, "node_modules/v3-dep")
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
writeFiles(directory.path, {
|
||||
"v3-dep-plugin/node_modules/effect/package.json":
|
||||
'{"name":"effect","version":"4.0.0-rc.111","type":"module","exports":{"./package.json":"./package.json"}}',
|
||||
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/package.json":
|
||||
'{"name":"effect","version":"3.19.19","type":"module","exports":{".":"./index.js","./Option":"./Option.js","./ReadonlyArray":"./ReadonlyArray.js"}}',
|
||||
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/index.js":
|
||||
"export const Effect = { major: 3 }; export const Schema = { major: 3 }",
|
||||
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/Option.js": "export const some = () => ({ major: 3 })",
|
||||
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/ReadonlyArray.js": "export const fromIterable = () => []",
|
||||
"v3-dep-plugin/node_modules/v3-dep/package.json":
|
||||
'{"name":"v3-dep","type":"module","exports":{".":"./index.js","./v3-only":"./v3-only.js"}}',
|
||||
"v3-dep-plugin/node_modules/v3-dep/index.js":
|
||||
'import { Effect, Schema } from "effect"; import { some } from "effect/Option"; export const v3DepCaptured = { Effect, Schema, some }',
|
||||
"v3-dep-plugin/node_modules/v3-dep/v3-only.js":
|
||||
'import { fromIterable } from "effect/ReadonlyArray"; export { fromIterable }',
|
||||
"v3-dep-plugin/index.ts":
|
||||
'import pkg from "effect/package.json" with { type: "json" }; import { v3DepCaptured } from "v3-dep"; export const effectPkgName = pkg.name; export { v3DepCaptured }; export default { id: "v3-dep-plugin", async setup() {} }',
|
||||
"removed-subpath-plugin/node_modules/effect/package.json":
|
||||
'{"name":"effect","type":"module","exports":{"./RemovedLegacySubpath":"./RemovedLegacySubpath.js"}}',
|
||||
"removed-subpath-plugin/node_modules/effect/RemovedLegacySubpath.js": "export const legacy = true",
|
||||
"removed-subpath-plugin/index.ts":
|
||||
'import { legacy } from "effect/RemovedLegacySubpath"; export default { id: "removed-subpath", async setup() { void legacy } }',
|
||||
}),
|
||||
)
|
||||
|
||||
const modules = yield* PluginModule.make()
|
||||
expect(yield* modules.load({ type: "add", target: pluginDir, options: {} })).toMatchObject({ id: "v3-dep-plugin" })
|
||||
|
||||
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
|
||||
expect(imported.effectPkgName).toBe("effect")
|
||||
expect(imported.v3DepCaptured.Effect).toBe(Effect)
|
||||
expect(imported.v3DepCaptured.Schema).toBe(Schema)
|
||||
expect(imported.v3DepCaptured.some).toBe(Option.some)
|
||||
yield* Effect.promise(async () => {
|
||||
await expect(import(path.join(v3DepDir, "v3-only.js"))).rejects.toThrow("effect/ReadonlyArray.js")
|
||||
})
|
||||
|
||||
const exit = yield* modules.load({ type: "add", target: badDir, options: {} }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(String(Cause.squash(exit.cause))).toContain("effect/RemovedLegacySubpath.js")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers exported specifiers from the resolved tree even when dist/ exists, and validates barrel loader routing, async loader dedup, and Windows foreign filters", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const consumerDir = path.join(directory.path, "consumer")
|
||||
const jitFixtureDir = path.join(directory.path, "jit-fixture")
|
||||
const asyncModPath = path.join(directory.path, "async-mod.ts")
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
writeFiles(directory.path, {
|
||||
"consumer/node_modules/@opencode/plugin/package.json": JSON.stringify({
|
||||
name: "@opencode/plugin",
|
||||
type: "module",
|
||||
exports: {
|
||||
".": "./src/promise/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./effect/*": "./src/effect/*.ts",
|
||||
},
|
||||
}),
|
||||
"consumer/node_modules/@opencode/plugin/src/promise/index.ts": "export const root = 'src'",
|
||||
"consumer/node_modules/@opencode/plugin/src/effect/index.ts": "export * as plugin from './plugin.ts'",
|
||||
"consumer/node_modules/@opencode/plugin/src/effect/plugin.ts": "export const leaf = 'src'",
|
||||
"consumer/node_modules/@opencode/plugin/src/unexported.ts": "export const secret = true",
|
||||
"consumer/node_modules/@opencode/plugin/dist/promise/index.js": "export const root = 'dist'",
|
||||
"consumer/node_modules/@opencode/plugin/dist/effect/index.js": "export const index = 'dist'",
|
||||
"consumer/node_modules/@opencode/plugin/dist/effect/plugin.js": "export const leaf = 'dist'",
|
||||
"jit-fixture/SchemaJITCompiler.js": "export const enable = () => 'fn'",
|
||||
"jit-fixture/enable.js": "export {}",
|
||||
"async-mod.ts": "await new Promise((r) => setTimeout(r, 20)); export const nonce = Math.random()",
|
||||
}),
|
||||
)
|
||||
|
||||
const discovered = discoverPluginRuntimeSpecifiers(consumerDir, ["@opencode/plugin"])
|
||||
expect(discovered.get("@opencode/plugin")?.replaceAll("\\", "/")).toEndWith("src/promise/index.ts")
|
||||
expect(discovered.get("@opencode/plugin/effect")?.replaceAll("\\", "/")).toEndWith("src/effect/index.ts")
|
||||
expect(discovered.get("@opencode/plugin/effect/plugin")?.replaceAll("\\", "/")).toEndWith("src/effect/plugin.ts")
|
||||
expect(discovered.has("@opencode/plugin/unexported")).toBe(false)
|
||||
|
||||
const hostDiscovered = new Map(discoverPluginRuntimeSpecifiers())
|
||||
hostDiscovered.set("effect/schema/SchemaJITCompiler", path.join(jitFixtureDir, "SchemaJITCompiler.js"))
|
||||
hostDiscovered.set("effect/schema/SchemaJITCompiler/enable", path.join(jitFixtureDir, "enable.js"))
|
||||
expect(pluginRuntimeLoaderCode("effect/Option", hostDiscovered)).toBe('() => require("effect")["Option"]')
|
||||
expect(pluginRuntimeLoaderCode("effect/testing", hostDiscovered)).toBe('() => require("effect/testing")')
|
||||
expect(pluginRuntimeLoaderCode("effect/unstable/http/MultipartParser/HeadersParser", hostDiscovered)).toBe(
|
||||
'() => require("effect/unstable/http/MultipartParser/HeadersParser")',
|
||||
)
|
||||
expect(pluginRuntimeLoaderCode("effect/schema/SchemaJITCompiler/enable", hostDiscovered)).toBe(
|
||||
'() => require("effect/schema/SchemaJITCompiler/enable")',
|
||||
)
|
||||
|
||||
const loadAsync = createLoader(asyncModPath)
|
||||
const first = loadAsync()
|
||||
const second = loadAsync()
|
||||
expect(first).toBeInstanceOf(Promise)
|
||||
expect(first).toBe(second)
|
||||
const [res1, res2] = yield* Effect.promise(() => Promise.all([first, second]))
|
||||
expect(res1).toBe(res2)
|
||||
|
||||
const winFilter = createForeignPackageFilter([
|
||||
"C:\\runner\\_work\\opencode\\node_modules\\.bun\\effect@4.0.0-rc.112\\node_modules\\effect",
|
||||
])
|
||||
expect(
|
||||
winFilter.test(
|
||||
"C:\\runner\\_work\\opencode\\node_modules\\.bun\\effect@4.0.0-rc.112\\node_modules\\effect\\dist\\index.js",
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
winFilter.test(
|
||||
"C:/runner/_work/opencode/node_modules/.bun/effect@4.0.0-rc.112/node_modules/effect/dist/index.js",
|
||||
),
|
||||
).toBe(false)
|
||||
expect(winFilter.test("C:\\Users\\plugin\\node_modules\\effect\\dist\\index.js")).toBe(true)
|
||||
expect(winFilter.test("C:/Users/plugin/node_modules/effect/dist/index.js")).toBe(true)
|
||||
expect(winFilter.test("C:\\Users\\user\\.bun\\install\\cache\\effect@4.0.1@@@1\\dist\\index.js")).toBe(true)
|
||||
expect(winFilter.test("C:/Users/user/.bun/install/cache/effect@4.0.1@@@1/dist/index.js")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
async function writeFiles(root: string, files: Record<string, string>) {
|
||||
await Promise.all(Object.entries(files).map(([file, text]) => Bun.write(path.join(root, file), text)))
|
||||
}
|
||||
@@ -1072,30 +1072,6 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("releases unread Console config responses when the fetch completes", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const signals: AbortSignal[] = []
|
||||
const state = { status: 404 }
|
||||
const http = HttpClient.make((request, _url, signal) => {
|
||||
signals.push(signal)
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unavailable", state)))
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: "https://console.test" } }),
|
||||
})
|
||||
yield* addPlugin().pipe(Effect.provideService(HttpClient.HttpClient, http))
|
||||
yield* drain
|
||||
state.status = 503
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
|
||||
// An unreleased response is only aborted when garbage collected, which fails outside a request on workerd.
|
||||
expect(signals.map((signal) => signal.aborted)).toEqual([true, true])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports a rejected Console refresh token as signed out without removing the credential", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
|
||||
@@ -42,21 +42,6 @@ describe("ShellScan adversarial corpus", () => {
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
// Only shell sinks evaluate subscripts; ordinary arguments, heredoc bodies, and bound data without a sink are safe.
|
||||
test.each([
|
||||
"bun -e 'f(`a[${x}]`)'",
|
||||
"rg 'a[$(x)]'",
|
||||
"cat <<'EOF'\na[$(x)]\nEOF",
|
||||
"echo 'a[$(x)]'",
|
||||
'BODY="- [x] Fix \\`scan.ts\\`"; gh pr create --title "fix" --body "$BODY"',
|
||||
"MSG='[feat] Fix `foo`'; git commit -m \"$MSG\"",
|
||||
"PATTERN='a[${b}]'; rg \"$PATTERN\"",
|
||||
"export REGEX='[0-9]+${foo}'",
|
||||
"cat <<< 'const a = arr[0]; const b = `${a}`'",
|
||||
])("scans subscript-shaped text outside shell sinks: %s", (input) => {
|
||||
expect(ShellScan.scan(input).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
test.each(['printf "unterminated', "printf ok &&", "printf ok >", "echo > >out"])(
|
||||
"keeps structurally uncertain Bash input opaque: %s",
|
||||
(input) => {
|
||||
@@ -64,19 +49,6 @@ describe("ShellScan adversarial corpus", () => {
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
["repeated assignment value operators", "x[a]" + "=]".repeat(32_000)],
|
||||
["unclosed assignment subscripts", "a[\n".repeat(21_000)],
|
||||
["case patterns with substitutions", "case x in " + "[$(:)".repeat(10_000)],
|
||||
["nested groups with bracket words", "{ ".repeat(31) + "echo " + "a[] ".repeat(15_000) + "; }".repeat(31)],
|
||||
["continued heredoc lines", "cat <<E\n" + "x\\\n".repeat(20_000) + "E\n"],
|
||||
["heredoc backslash runs", "cat <<E\n" + "\\".repeat(60_000) + "x\nE\n"],
|
||||
])("scans adversarial Bash input in bounded time: %s", (_, input) => {
|
||||
const start = performance.now()
|
||||
ShellScan.scan(input)
|
||||
expect(performance.now() - start).toBeLessThan(500)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
|
||||
["Import-Module ./evil.psm1", ["Import-Module"]],
|
||||
|
||||
@@ -60,41 +60,23 @@ const fixtures = [
|
||||
["! scan_probe", ["scan_probe"]],
|
||||
["time scan_probe", ["time"]],
|
||||
["{fd}>/dev/null scan_probe", ["scan_probe"]],
|
||||
["case $r in a) ls | head;; esac", ["ls", "head"]],
|
||||
["case $r in a) ls && echo;; esac", ["ls", "echo"]],
|
||||
["{ find . -exec echo {} \\; ; }", ["find"]],
|
||||
["{ echo {a,{b,c}}; }", ["echo"]],
|
||||
["if true; then\\\n echo hi; fi", ["true", "echo"]],
|
||||
["for ((i=0; i<2; i++)) do echo hi; done", ["echo"]],
|
||||
['echo "$(case x in @(a)) echo hi;; esac)"', ["echo", "echo"]],
|
||||
["set -- 1; for x do scan_probe; done", ["set", "scan_probe"]],
|
||||
["'q'; x=1 a", ["q", "a"]],
|
||||
["cat <<E; ( true\nscan_ignored\nE\n)", ["cat", "true"]],
|
||||
["cat <<E; f() { true\nscan_ignored\nE\n}; f", ["cat", "true", "f"]],
|
||||
["cat <<E; case x in\nscan_ignored\nE\nx) scan_probe;; esac", ["cat", "scan_probe"]],
|
||||
["time [[ ( -f foo ) ]]", []],
|
||||
["time ! { echo a; echo b; }", ["echo", "echo"]],
|
||||
] as const
|
||||
|
||||
describe("ordinary Bash and Zsh syntax", () => {
|
||||
test.each(fixtures)("extracts actual command nodes: %s", (source, names) => {
|
||||
for (const dialect of ["bash", "zsh"] as const) {
|
||||
const result = ShellScan.scan(source, dialect)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
}
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
for (const [source] of fixtures) {
|
||||
// These are Bash spellings; Zsh's fd allocation is a standalone statement. Bash parses extglob
|
||||
// patterns only when extglob is enabled.
|
||||
// These are Bash spellings; Zsh's fd allocation is a standalone statement.
|
||||
test.skipIf(
|
||||
!executable ||
|
||||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))) ||
|
||||
(shell === "bash" && source.includes("@(")),
|
||||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))),
|
||||
)(`${shell} accepts the source grammar: ${source}`, () => {
|
||||
const result = Bun.spawnSync([
|
||||
executable ?? shell,
|
||||
@@ -150,15 +132,10 @@ describe("ordinary Bash and Zsh syntax", () => {
|
||||
"cat <<EOF\nunclosed",
|
||||
"echo ${missing",
|
||||
"echo $'missing",
|
||||
"case $r in a) ls |;; esac",
|
||||
])("rejects incomplete syntax: %s", (source) => {
|
||||
expect(ShellScan.scan(source).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test("scans a Zsh brace group closed after a redirect", () => {
|
||||
expect(ShellScan.scan("{ a && >f }")).toMatchObject({ kind: "scanned", commands: [{ words: ["a"] }] })
|
||||
})
|
||||
|
||||
test("preserves raw lexical spelling of ANSI-C and locale quoted words", () => {
|
||||
expect(ShellScan.scan("$'pri\\x6etf' $'line\\n' $\"text\"")).toMatchObject({
|
||||
kind: "scanned",
|
||||
@@ -168,7 +145,7 @@ describe("ordinary Bash and Zsh syntax", () => {
|
||||
|
||||
test.each([
|
||||
["coproc job { scan_probe; }", ["scan_probe"]],
|
||||
["case x in @(one|$(scan_probe))) ;; esac", ["scan_probe"]],
|
||||
["printf '%s' @(one|$(scan_probe))", ["printf", "scan_probe"]],
|
||||
["printf '%s' $((1 + '$(scan_probe)'))", ["printf", "scan_probe"]],
|
||||
["printf '%s' $(((1 + '$(scan_probe)')))", ["printf", "scan_probe"]],
|
||||
['printf %s "${ scan_probe; }"', ["printf", "scan_probe"]],
|
||||
@@ -264,56 +241,3 @@ describe("Bash shared heredoc delimiter grammar", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Bash dialects", () => {
|
||||
const heads = (source: string, dialect?: ShellScan.Dialect) => {
|
||||
const result = ShellScan.scan(source, dialect)
|
||||
return result.kind === "scanned" ? result.commands.map((command) => command.words[0]) : result.kind
|
||||
}
|
||||
|
||||
test("posix reports both readings of a double parenthesis", () => {
|
||||
expect(heads("(( x = 1 )); y", "bash")).toEqual(["y"])
|
||||
expect(heads("(( x = 1 )); y", "zsh")).toEqual(["y"])
|
||||
expect(heads("(( x = 1 )); y", "posix")).toEqual(["x", "y"])
|
||||
expect(heads("(( x = 1 )); y")).toEqual(["x", "y"])
|
||||
expect(heads("(( (1) + (2) ))", "bash")).toEqual([])
|
||||
expect(heads("(( (1) + (2) ))", "posix")).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each([
|
||||
["true &>/dev/null next", ["true"], "opaque"],
|
||||
["X=$[1 + 2] next", ["next"], "opaque"],
|
||||
] as const)("reads Bash and Zsh syntax precisely where Dash diverges: %s", (source, precise, posix) => {
|
||||
expect(heads(source, "bash")).toEqual([...precise])
|
||||
expect(heads(source, "zsh")).toEqual([...precise])
|
||||
expect(heads(source, "posix")).toEqual(posix)
|
||||
})
|
||||
|
||||
test("reads a reserved word after a redirect as a Zsh keyword", () => {
|
||||
const source = "if true; then >/dev/null fi; next"
|
||||
expect(heads(source, "zsh")).toEqual(["true", "next"])
|
||||
expect(heads(source, "bash")).toBe("opaque")
|
||||
expect(heads(source, "posix")).toBe("opaque")
|
||||
})
|
||||
|
||||
test("scans safe Zsh parameter flags, Zsh repeat loops, and Bash extglob arguments", () => {
|
||||
expect(heads("print -l ${(M)files:#*.ts}", "zsh")).toEqual(["print"])
|
||||
expect(heads("print -l ${(ps:\\n:)text}", "zsh")).toEqual(["print"])
|
||||
expect(heads("print -r -- ${(q-)x} ${(q+)x} ${(on-)x}", "zsh")).toEqual(["print"])
|
||||
expect(heads("repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
|
||||
expect(heads("repeat 3; do echo hi; done", "posix")).toEqual(["echo"])
|
||||
expect(heads("f() repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
|
||||
expect(heads("ls @(foo|bar)", "bash")).toEqual(["ls"])
|
||||
})
|
||||
|
||||
test.each([
|
||||
["/bin/bash", ["y"]],
|
||||
["/usr/local/bin/zsh", ["y"]],
|
||||
["/bin/sh", ["x = 1", "y"]],
|
||||
["/bin/dash", ["x = 1", "y"]],
|
||||
["/opt/bin/mksh", ["x = 1", "y"]],
|
||||
] as const)("derives the dialect from the shell executable: %s", async (shell, resources) => {
|
||||
const result = await Effect.runPromise(ShellParse.scanPortable("(( x = 1 )); y", shell, "/workspace"))
|
||||
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
|
||||
})
|
||||
})
|
||||
@@ -1,751 +0,0 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { Effect, Exit } from "effect"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const shellCandidates = ["/bin/bash", "/opt/homebrew/bin/bash", "/usr/local/bin/bash", "bash", "zsh", "dash"]
|
||||
const shells = [
|
||||
...new Set(
|
||||
shellCandidates
|
||||
.map((item) => (item.startsWith("/") ? (fs.existsSync(item) ? item : undefined) : Bun.which(item)))
|
||||
.filter((item): item is string => Boolean(item)),
|
||||
),
|
||||
]
|
||||
// Fixtures target specific shells and versions (bash 3.2 and 5.3, zsh, dash). Set SHELL_ORACLE_STRICT=1 on a
|
||||
// machine with all of them to also verify that every fixture still executes somewhere.
|
||||
const strict = process.env.SHELL_ORACLE_STRICT === "1"
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-shell-oracle-"))
|
||||
const bin = path.join(root, "bin")
|
||||
const target = path.join(root, "target")
|
||||
const log = path.join(root, "log")
|
||||
fs.mkdirSync(bin)
|
||||
fs.mkdirSync(target)
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "scan_probe"),
|
||||
'#!/bin/sh\nprintf \'%s\\n\' "scan_probe${1:+ $*}" >> "$SCAN_PROBE_LOG"\n',
|
||||
{
|
||||
mode: 0o755,
|
||||
},
|
||||
)
|
||||
// Dash runs `scan_probe[x y]=1` as the command `scan_probe[x`.
|
||||
fs.copyFileSync(path.join(bin, "scan_probe"), path.join(bin, "scan_probe[x"))
|
||||
// Zsh treats a trailing parenthesized group after an existing file name as glob qualifiers.
|
||||
fs.writeFileSync(path.join(root, "a="), "")
|
||||
|
||||
afterAll(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
function shellFlags(executable: string) {
|
||||
if (executable.endsWith("/bash")) return ["--noprofile", "--norc"]
|
||||
if (executable.endsWith("/zsh")) return ["-f"]
|
||||
return []
|
||||
}
|
||||
|
||||
// Runs source in a real shell and returns the lines it logged.
|
||||
function observe(executable: string, source: string) {
|
||||
fs.writeFileSync(log, "")
|
||||
Bun.spawnSync([executable, ...shellFlags(executable), "-c", source], {
|
||||
cwd: root,
|
||||
env: { PATH: `${bin}:/usr/bin:/bin`, HOME: root, LC_ALL: "C", SCAN_PROBE_LOG: log },
|
||||
timeout: 2_000,
|
||||
})
|
||||
return fs
|
||||
.readFileSync(log, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Each dialect must report what its shells run; posix, the default, covers every shell.
|
||||
const dialects = {
|
||||
bash: (executable: string) => path.basename(executable) === "bash",
|
||||
zsh: (executable: string) => path.basename(executable) === "zsh",
|
||||
posix: () => true,
|
||||
} satisfies Record<ShellScan.Dialect, (executable: string) => boolean>
|
||||
|
||||
function expectProbesReported(source: string) {
|
||||
const runs = shells.map((executable) => [executable, observe(executable, source)] as const)
|
||||
if (strict)
|
||||
expect(
|
||||
runs.some(([, invocations]) => invocations.length > 0),
|
||||
`Fixture never executed scan_probe in any real shell: ${source}`,
|
||||
).toBe(true)
|
||||
expect(ShellScan.scan(source)).toEqual(ShellScan.scan(source, "posix"))
|
||||
for (const [dialect, runsIn] of Object.entries(dialects)) {
|
||||
const result = ShellScan.scan(source, dialect as ShellScan.Dialect)
|
||||
if (result.kind === "opaque") continue
|
||||
const reported = result.commands.filter((command) => !command.declaration).map((command) => command.words.join(" "))
|
||||
for (const [executable, invocations] of runs.filter(([executable]) => runsIn(executable)))
|
||||
for (const invocation of invocations)
|
||||
expect(reported, `${executable} executed ${invocation} with ${dialect} in: ${source}`).toContain(invocation)
|
||||
}
|
||||
}
|
||||
|
||||
// Each fixture runs scan_probe in at least one real shell, which the scanner must report or reject.
|
||||
const fixtures = [
|
||||
"a=(1)scan_probe",
|
||||
"a=(\n1)scan_probe; ",
|
||||
"a=(1)'scan_probe'",
|
||||
"alias p=scan_probe\np",
|
||||
"command alias p=scan_probe\np",
|
||||
"x='*(e:scan_probe:)'; echo $^~x",
|
||||
"x='$(scan_probe)'; echo \"${\\\n(e)x}\"",
|
||||
"x='$(scan_probe)'; echo ${(j:):e)x}",
|
||||
"x='*(e:scan_probe:)'; echo ${(f)~x}",
|
||||
"x='*(e:scan_probe:)'; echo ${=~x}",
|
||||
"x='*(e:scan_probe:)'; echo $=~x",
|
||||
"x='$(scan_probe)'; echo ${(%e)x}",
|
||||
"x='$(scan_probe)'; echo ${(s.:.e)x}",
|
||||
"x='$(scan_probe)'; echo ${(s(:)e)x}",
|
||||
"x='$(scan_probe)'; echo ${(ej: :)x}",
|
||||
"x='*(e:scan_probe:)'; echo ${(s: :)~x}",
|
||||
"x='*(e:scan_probe:)'; echo ${^~x}",
|
||||
"printf -v x 'a[$(scan_probe)]'; echo $((x))",
|
||||
"a=(1); getopts a: x -a 'a[$(scan_probe)]'; echo $((OPTARG))",
|
||||
"command -- declare 'a[$(scan_probe)]=1'",
|
||||
"a=(1); unset 'a[b[$\\\n(scan_probe)0]]'",
|
||||
'declare \'a["\\"]"$(scan_probe)0]=1\'',
|
||||
"declare -a 'a=(+ [$(scan_probe)]=1)'",
|
||||
"a=(1); echo ${a[b[\\$(scan_probe)]]}",
|
||||
'a["b[\\$(scan_probe)]"]=1',
|
||||
'b=1; a["b[\\$(scan_probe)1]"]=1',
|
||||
"a=(1); echo $[ ${x:-a[\\$(scan_probe)1]} ]",
|
||||
"a=(1); s=abc; echo ${s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); s=abc; echo ${s:${x:-'a[$(scan_probe)1]'}}",
|
||||
"read x <<< 'a[$(scan_probe f)]'; echo $((x))",
|
||||
"mapfile -t a <<< 'b[$(scan_probe g)]'; echo $((a[0]))",
|
||||
"x=1; echo $(( x[\\$(scan_probe i)] ))",
|
||||
"a=(1); echo ${a[@]:'a[$(scan_probe k)]'}",
|
||||
"builtin -- declare 'a[$(scan_probe)]=1'",
|
||||
"command -p declare 'a[$(scan_probe)]=1'",
|
||||
'echo "${unset:+${x[\'"\']}}"]}} \'$(scan_probe)\' " # "',
|
||||
"echo \"${unset:+${x['}}\"']}}'; scan_probe # \"",
|
||||
"echo \"${x:-$'\\''}\"; scan_probe # '\"",
|
||||
"cat <<E\n${x:-'}'}' $(scan_probe)\nE",
|
||||
'echo "${x:-a\'b}c\'d}"$(scan_probe)"\'"',
|
||||
"echo \"${PATH//:/$'\\n'}\"; scan_probe ok",
|
||||
"echo $(( : # ))'\n); scan_probe ) # '",
|
||||
"echo $(( $(echo 1) # ))'\n); scan_probe ) # '",
|
||||
"(( : # ))'\n); scan_probe ) # '",
|
||||
"echo $(( 16#ff + 2#1 + $# + ${#x} )); scan_probe ok",
|
||||
"(( scan_probe ))",
|
||||
"(( (scan_probe) & (scan_probe) ))",
|
||||
"(( (scan_probe)\n(scan_probe) ))",
|
||||
"cat <<'}'; {\n:\n}\nscan_probe; cat <<'}'; }\n}",
|
||||
"cat <<'x)'; case x in\nx)\nx) scan_probe; cat <<'x)'\nx)\n;; esac",
|
||||
"cat <<'if'; f()\nif\nif scan_probe; cat <<'if'\nif\ntrue; then :; fi; f",
|
||||
"cat <<E; ( true\nE\nscan_probe g\n)",
|
||||
"cat <<E; { true\nE\nscan_probe g\n}",
|
||||
"cat <<E; f() { true\nE\nscan_probe f\n}; f",
|
||||
"cat <<E; if true\nE\nscan_probe i\nthen :; fi",
|
||||
"cat <<E; echo $(true\nscan_probe s\n)\nE\nscan_probe after",
|
||||
"cat <<E; echo `true\nscan_probe b\n`\nE\nscan_probe after",
|
||||
"cat <<E; cat <(true\nscan_probe s\n)\nE\nscan_probe after",
|
||||
"cat <<E; x=$(cat <<F\nF\n)\nE\nscan_probe out",
|
||||
"{ cat <<E; }\nscan_probe x\nE\nscan_probe y",
|
||||
"[[ a]]b# ]] && scan_probe",
|
||||
'[[ "a]]"# ]] && scan_probe',
|
||||
"[[ 'a]]'# ]] && scan_probe",
|
||||
"[[ a]]b = a]]b ]] && scan_probe",
|
||||
'{ export X="a"{}# ; scan_probe; }',
|
||||
"{ export X=a}# ; scan_probe; }",
|
||||
"{ case esac in (esac) scan_probe;; esac\n}",
|
||||
"unset a[b\nscan_probe\necho ]+=1",
|
||||
"export a[b; scan_probe; echo ]=1",
|
||||
"a[b; scan_probe; echo ]=1",
|
||||
"a[0 #]\n]=1; scan_probe",
|
||||
"for i in 1; { scan_probe; }; while false; do export X=1; done",
|
||||
"for ((i=0; i<1; i++)); { scan_probe; }; while false; do export X=1; done",
|
||||
"for i in 1; scan_probe",
|
||||
"((( echo '\"' ); scan_probe; ( echo '\"' )))",
|
||||
"set=1; export X=${set:-${x['0\"0']}}; scan_probe; : '\"]}}' # '",
|
||||
"export X=$$$$'\\'; scan_probe # '",
|
||||
'export X="`export Y=\\"\'\\" ; scan_probe; export Z=\\"\'\\"`"',
|
||||
'export X="`echo \\"(\\"; scan_probe; echo \\")\\"`"',
|
||||
": <<-export\n\tex\\\n\tport\n$(scan_probe)\nexport",
|
||||
'export X="${\\\n scan_probe; }"',
|
||||
'export X="${\n scan_probe; }"',
|
||||
'export X="${|\\\n REPLY=$(scan_probe); }"',
|
||||
"!(scan_probe; true)",
|
||||
"if !(scan_probe); then :; fi",
|
||||
"f+() { scan_probe; }; f+",
|
||||
': <<$"export"\n$export\nscan_probe\nexport',
|
||||
': <<$"export"\nexport\nscan_probe\n$export',
|
||||
"export X=$[1; scan_probe; : ]",
|
||||
"X=$[1 scan_probe ]",
|
||||
'export X=$["]"]; scan_probe # ]',
|
||||
"export X=${#}# ; scan_probe",
|
||||
"export X=$#a# ; scan_probe",
|
||||
"export X=~+# ; scan_probe",
|
||||
"{ export X=1; }# ; scan_probe; }",
|
||||
"if true; then export X=1; fi# ; scan_probe; fi",
|
||||
"for i in 1; do export X=1; done# ; scan_probe; done",
|
||||
"case esac# in x) export X=1;; esac#) scan_probe;; esac",
|
||||
": <<A <<B\nA\n$(scan_probe)\nB",
|
||||
'export X="$(cat <<EOF\n)\nEOF\nscan_probe)"',
|
||||
"cat <<EOF | scan_probe\nhello\nEOF",
|
||||
"cat <<EOF && scan_probe\nhello\nEOF",
|
||||
"cat <<'E'\"O\"F\n# $(scan_probe)\nEOF\nscan_probe",
|
||||
"cat <<E\\\nOF\n$(scan_probe)\nEOF",
|
||||
"cat <<-EOF\n\t EOF\n$(scan_probe)\n\tEOF",
|
||||
"cat <<export\nex\\\nport\n# $(scan_probe)\nexport",
|
||||
"cat <<< 'a'\"$(scan_probe)\"",
|
||||
'export X=${x:-"}"}; scan_probe',
|
||||
"export X=${x:-'}'}; scan_probe",
|
||||
'export X="${x:-"}"}"; scan_probe',
|
||||
'export X="${x:-"$(scan_probe)"}"',
|
||||
"export X=${x//\\}/}; scan_probe; : }",
|
||||
'export X="${x//\\}/}"; scan_probe; : "}"',
|
||||
"export X=${x#${y}}; scan_probe",
|
||||
'export X="${x#${y:-"}"}}"; scan_probe',
|
||||
"export X=${!prefix*}; scan_probe",
|
||||
"export X=${x@Q}; scan_probe",
|
||||
"export X=\"${unset:+${x['\"']}}\"; scan_probe; : '\"}]}'",
|
||||
'export X="${unset:+${x[\'}}\"; scan_probe; : \"\']}}"',
|
||||
'echo $(case x in x) echo ")" ;; esac; scan_probe)',
|
||||
"echo \"$(case x in (x) echo ')' ;; esac; scan_probe)\"",
|
||||
'echo "`echo \\"$(scan_probe)\\"`"',
|
||||
"echo `echo \\`scan_probe\\``",
|
||||
"echo $( (echo a); scan_probe )",
|
||||
"echo $((echo a); scan_probe)",
|
||||
"if true; then scan_probe; elif true; then :; else :; fi",
|
||||
"for i in in do done esac; do scan_probe; done",
|
||||
"for do in 1; do scan_probe; done",
|
||||
"for in in 1; do scan_probe; done",
|
||||
"for x\nin 1; do scan_probe; done",
|
||||
"set -- 1; for x\ndo scan_probe; done",
|
||||
"set -- 1; for x do scan_probe; done",
|
||||
"case in in in) scan_probe;; esac",
|
||||
"case esac in a|esac) scan_probe;; esac",
|
||||
"case y in x) ;; y) scan_probe;; esac",
|
||||
"case x in x) scan_probe ;& y) : ;;& z) : ;; esac",
|
||||
"case x in x) scan_probe ;| y) : ;; esac",
|
||||
"case [ in [) scan_probe & ( scan_probe q ]) ;; esac",
|
||||
"case x in (x|[) scan_probe & ( scan_probe q ]) ;; esac",
|
||||
"while false; do :; done & scan_probe",
|
||||
"{ scan_probe & }",
|
||||
"( scan_probe & )",
|
||||
"[[ b =~ b ]] && scan_probe",
|
||||
"[[ ( a == a ) && ( b == b ) ]] && scan_probe",
|
||||
'[[ "$(scan_probe)" == "]]" ]]',
|
||||
"(( 1 + $(scan_probe) ))",
|
||||
"(( a = 1 )) && scan_probe",
|
||||
"(( (1) + (2) )); scan_probe",
|
||||
"(( (echo a); scan_probe ))",
|
||||
">/dev/null scan_probe",
|
||||
"2>&1 scan_probe",
|
||||
"A=1 >/dev/null B=2 scan_probe",
|
||||
'export A=1 B="$(scan_probe)"',
|
||||
'declare -a arr=(1 "$(scan_probe)")',
|
||||
"export X=$'a'\\\n; scan_probe",
|
||||
"case x in \\\nx) \\\nscan_probe;; \\\nesac",
|
||||
"if true; then \\\nscan_probe; fi",
|
||||
'() { :; } "$(scan_probe)"',
|
||||
"function f() ( scan_probe ); f",
|
||||
"{ export X=1 } && scan_probe",
|
||||
"{ export X=1 } ; scan_probe ; }",
|
||||
|
||||
// Dialects disagree about reserved words after redirects and operators split by line continuations.
|
||||
"if true; then >/dev/null fi; scan_probe; fi",
|
||||
"if true; then >/dev/null fi; scan_probe",
|
||||
"case x in x) >/dev/null esac; scan_probe; esac",
|
||||
"true &>/dev/null scan_probe",
|
||||
"true &>>/dev/null scan_probe",
|
||||
"true &\\\n>/dev/null scan_probe",
|
||||
"cat <<\\\n-EOF\nEOF\nscan_probe h1\n-EOF",
|
||||
"cat <<\\\n-EOF\n-EOF\nscan_probe z\nEOF",
|
||||
"cat <\\\n(scan_probe i)",
|
||||
"[[ -n <\\\n(scan_probe c1) ]]",
|
||||
"a=(<\\\n(scan_probe a1))",
|
||||
"cat <<E\n$\\\n(scan_probe h)\nE",
|
||||
"echo ${x:-$\\\n(scan_probe p)}",
|
||||
"(( $\\\n(scan_probe a) ))",
|
||||
"[[ $\\\n(scan_probe c) ]]",
|
||||
"a=($\\\n(scan_probe arr))",
|
||||
|
||||
// Dash splits assignment subscripts at blanks.
|
||||
"scan_probe[x y]=1",
|
||||
"a[x '$(scan_probe)']=1",
|
||||
"a[1 + $(scan_probe)]=1",
|
||||
|
||||
// Expansions inside parameter words, arithmetic, and subscripts.
|
||||
"echo \"${x:-$'$(scan_probe q1)'}\"",
|
||||
"echo ${x:-<(scan_probe p1)}",
|
||||
"echo ${x:->(scan_probe g)}",
|
||||
"x=${y:-<(scan_probe p4)}",
|
||||
"[[ x == ${y:-<(scan_probe p5)} ]]",
|
||||
"echo $(( $'$(scan_probe a)' ))",
|
||||
"(( x = $'$(scan_probe b)' ))",
|
||||
"echo $(( ${x:-'$(scan_probe a)'} ))",
|
||||
"(( ${x:-'$(scan_probe b)'} ))",
|
||||
"a[${x:-'$(scan_probe c)'}]=1",
|
||||
"echo $[ ${x:-'$(scan_probe d)'} ]",
|
||||
"echo $[ $'$(scan_probe h)' ]",
|
||||
"echo ${a[${x:-'$(scan_probe e)'}]}",
|
||||
"echo \"${a[${x:-'$(scan_probe f)'}]}\"",
|
||||
"cat <<E\n${x:-'$(scan_probe h)'}\nE",
|
||||
|
||||
// Zsh glob qualifiers and extglob groups run code in globbed words.
|
||||
"echo @(<(scan_probe e1))",
|
||||
"printf '%s' @(one|$(scan_probe))",
|
||||
"echo *(e:'scan_probe q1':)",
|
||||
"echo *(+scan_probe)",
|
||||
"a=(*(e:'scan_probe g':))",
|
||||
"declare -a a=(*(e:'scan_probe g':))",
|
||||
"export a=(*(e:'scan_probe h':))",
|
||||
"for f in *(e:'scan_probe h':); do :; done",
|
||||
"echo ${x:-target(e:'scan_probe p2':)}",
|
||||
"echo ${x:-*(e:'scan_probe j':)}",
|
||||
"echo a=(e:'scan_probe p3':)",
|
||||
"echo >*(e:'scan_probe f':)",
|
||||
"cat <*(e:'scan_probe g':)",
|
||||
"echo $x*(e:'scan_probe h':)",
|
||||
"echo \"\"*(e:'scan_probe i':)",
|
||||
"echo {a,*(e:'scan_probe m':)}",
|
||||
|
||||
// Builtins and arithmetic evaluate subscripts in decoded literal text.
|
||||
"declare -i x='a[$(scan_probe)]'",
|
||||
"declare 'a[$(scan_probe)]=1'",
|
||||
"declare -a 'a=([$(scan_probe)]=1)'",
|
||||
"a=(1); unset 'a[$(scan_probe)]'",
|
||||
'a=(1); unset "a[\\$(scan_probe)]"',
|
||||
"[[ 'a[$(scan_probe)]' -eq 1 ]]",
|
||||
"[[ -v 'a[$(scan_probe)]' ]]",
|
||||
"read 'a[$(scan_probe)]' </dev/null",
|
||||
"printf -v 'a[$(scan_probe)]' x",
|
||||
"x='a[$(scan_probe)]'; echo $((x))",
|
||||
"s=abc; x='a[$(scan_probe)0]'; printf '%s' \"${s:x}\"",
|
||||
"ref='x[$(scan_probe)0]'; printf '%s' \"${!ref}\"",
|
||||
"declare ${x:-'a[$(scan_probe)]=1'}",
|
||||
'declare "${x:-a[\\$(scan_probe)]=1}"',
|
||||
"read ${x:-'a[$(scan_probe)]'} </dev/null",
|
||||
"declare \"$(echo 'a[$(scan_probe)]=1')\"",
|
||||
"declare \"${ echo a; }\"'[$(scan_probe)]=1'",
|
||||
"n=a; declare \"$n\"'[$(scan_probe)]=1'",
|
||||
"declare \"$(echo a)\"'[$(scan_probe)]=1'",
|
||||
"builtin declare 'a[$(scan_probe)]=1'",
|
||||
"printf -v'a[$(scan_probe)]' x",
|
||||
"set -- 'a[$(scan_probe)]'; echo $(($1))",
|
||||
"for x in 'a[$(scan_probe)]'; do echo $((x)); done",
|
||||
"a=(1 'a[$(scan_probe)]'); echo $((a[1]))",
|
||||
"x='a[$(scan_probe)]' eval 'echo $((x))'",
|
||||
"a=(1); echo $(( a[\\$(scan_probe)] ))",
|
||||
"a=(1); (( a[\\$(scan_probe)] ))",
|
||||
|
||||
// Explicit evaluation operators.
|
||||
"x='$(scan_probe)'; echo ${x@P}",
|
||||
"x='$(scan_probe)'; echo \"${x@P}\"",
|
||||
"x='$(scan_probe)'; echo ${(e)x}",
|
||||
"x='$(scan_probe)'; echo ${(ee)x}",
|
||||
"echo ${(e):-'$(scan_probe)'}",
|
||||
"x='*(e:scan_probe:)'; echo ${~x}",
|
||||
"x='*(e:scan_probe:)'; echo $~x",
|
||||
|
||||
// Precommand modifiers, bundled/ordered binding options, repeat, compgen, and setopt.
|
||||
"noglob typeset 'a[$(scan_probe)]=1'",
|
||||
"nocorrect typeset 'a[$(scan_probe)]=1'",
|
||||
"a=(1); noglob unset 'a[$(scan_probe)]'",
|
||||
"noglob read 'a[$(scan_probe)]' <<< x",
|
||||
'a=(1); noglob let "a[\\$(scan_probe)]"',
|
||||
"print -r -v 'a[$(scan_probe)]' x",
|
||||
"print -rv 'a[$(scan_probe)]' x",
|
||||
"a=(1); print -r -v x 'a[$(scan_probe)]'; echo $((x))",
|
||||
"a=(1); print -rv x 'a[$(scan_probe)]'; echo $((x))",
|
||||
": & wait -np 'a[$(scan_probe)]'",
|
||||
": & wait -n -p'a[$(scan_probe)]'",
|
||||
": & wait -fp 'a[$(scan_probe)]' $!",
|
||||
"time declare 'a[$(scan_probe)]=1'",
|
||||
"coproc declare 'a[$(scan_probe)]=1'",
|
||||
"a=(1); time unset 'a[$(scan_probe)]'",
|
||||
"a=(1); time let 'a[$(scan_probe)]'",
|
||||
"a=(1); time [[ -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); time [[ 'a[$(scan_probe)]' -eq 1 ]]",
|
||||
"a=(1); coproc [[ -v 'a[$(scan_probe)]' ]]",
|
||||
'export X="${ time ! { :; }; scan_probe; }"',
|
||||
'export X="${ time\\\n { :; }; scan_probe; }"',
|
||||
'export X="${ time -- { :; }; scan_probe; }"',
|
||||
"repeat 1 scan_probe",
|
||||
"a=(1); repeat 'a[$(scan_probe)]' :",
|
||||
"setopt globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"set -o globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"compgen -C scan_probe",
|
||||
"compgen -C 'scan_probe'",
|
||||
|
||||
// Combined literal/output subscripts, deferred bindings, continuations, Zsh flags, and ANSI-C escapes.
|
||||
"declare \"a[$(echo '$(scan_probe)')]=1\"",
|
||||
"declare \"$(echo 'a[')\"'$(scan_probe)]=1'",
|
||||
"a=(1); unset \"a[$(echo '$(scan_probe)')]\"",
|
||||
"a=(1); echo $(( a[$(echo '$(scan_probe)')] ))",
|
||||
"x='$(scan_probe)'; declare \"a[$x]=1\"",
|
||||
"x='$(scan_probe)'; a=(1); unset \"a[$x]\"",
|
||||
"x='$(scan_probe)'; let \"a[$x]=1\"",
|
||||
"x='$(scan_probe)'; read \"a[$x]\" <<< 1",
|
||||
"x='$(scan_probe)'; printf -v \"a[$x]\" 1",
|
||||
"a=(1); x='$(scan_probe)'; echo $((a[$x]))",
|
||||
"a=(1); x='$(scan_probe)'; [[ -v \"a[$x]\" ]]",
|
||||
"a=(1); x='$(scan_probe)'; [[ \"a[$x]\" -eq 1 ]]",
|
||||
"a=(1); echo ${a\\\n['$(scan_probe)']}",
|
||||
"a=(1); s=abc; echo ${s\\\n:'a[$(scan_probe)0]'}",
|
||||
"a=(1); echo ${a[0]\\\n:'a[$(scan_probe)0]'}",
|
||||
"x='$(scan_probe)'; echo ${x@\\\nP}",
|
||||
"x='$(scan_probe)'; echo \"${x@\\\nP}\"",
|
||||
"x='$(scan_probe)'; echo ${x@P\\\n}",
|
||||
"x='*(e:scan_probe:)'; echo $\\\n~x",
|
||||
"x='*(e:scan_probe:)'; echo $^\\\n~x",
|
||||
"x='*(e:scan_probe:)'; echo ${^\\\n~x}",
|
||||
"x='*(e:scan_probe:)'; echo ${(f)\\\n~x}",
|
||||
"a=(1); echo ${(f)a['a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${=a['a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${^a['a[$(scan_probe)]']}",
|
||||
"a=(1); s=abc; echo ${(f)s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); s=abc; echo ${=s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); s=abc; echo ${^s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ x != *(a)]] && -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ x != (a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"shopt -s extglob\na=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); echo $(( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
|
||||
"a=(1); (( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
|
||||
"a=(1); echo ${a[$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d']}",
|
||||
"a=(1); declare \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d=1'}\"",
|
||||
"a=(1); unset \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d'}\"",
|
||||
"(( $'\\' )) # ' )); scan_probe",
|
||||
"declare -A a; a[$'\\']=1 # ']=1; scan_probe",
|
||||
"a=(1); echo ${a[$'\\']} # ']} $(scan_probe)",
|
||||
": <<$'export'\n$export\nscan_probe\nexport",
|
||||
": <<$'EOF'\n$EOF\nscan_probe\nEOF",
|
||||
"echo $'\\'; scan_probe; : ' # '",
|
||||
"export X=$'\\'; scan_probe; : ' # '",
|
||||
"export X=$'\\c\\\\'; scan_probe # '",
|
||||
"export X=$'\\c'; scan_probe; : ' # '",
|
||||
"$'scan_probe\\0x'",
|
||||
"$'scan_probe\\x00x'",
|
||||
"$'scan_probe\\u0000x'",
|
||||
"$'scan_probe\\c@x'",
|
||||
"$'declare\\0x' 'a[$(scan_probe)]=1'",
|
||||
"a=(1); $'unset\\0x' 'a[$(scan_probe)]'",
|
||||
"$'let\\0x' 'a[$(scan_probe)]'",
|
||||
"x='x y'; scan_probe[$x]=1",
|
||||
|
||||
// Parameter default bindings, Zsh subscripts, precommands, namerefs, ANSI-C escapes, and Zsh builtins.
|
||||
"a=(1); : ${x:='a[$(scan_probe)]'}; echo $((x))",
|
||||
"a=(1); : ${x:='$(scan_probe)'}; echo $((a[$x]))",
|
||||
"a=(1); : ${x='$(scan_probe)'}; unset \"a[$x]\"",
|
||||
'a=(1); : "${x:="a[\\$(scan_probe)]"}"; echo $((x))',
|
||||
"a=(); : ${a[0]:='a[$(scan_probe)]'}; echo $((a[0]))",
|
||||
"declare -i a x; : ${x:='a[$(scan_probe)]'}",
|
||||
"a=(1); echo ${@[1,'a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${*[1,'a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${1[1,'a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${a[1]['$(scan_probe)']}",
|
||||
"a=(1); echo ${s[1][1]:'a[$(scan_probe)0]'}",
|
||||
"a=(1); echo ${${a}['$(scan_probe)']}",
|
||||
"a=(1); echo ${${s}:'a[$(scan_probe)0]'}",
|
||||
"a=(1); echo $a['$(scan_probe)']",
|
||||
'a=(1); echo "$a[a[\\$(scan_probe)]]"',
|
||||
"a=(1); x='$(scan_probe)'; echo $a[a[$x]]",
|
||||
"a=(1); x='$(scan_probe)'; echo \"$a[a[$x]]\"",
|
||||
"a=(1); echo $@[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $^a['$(scan_probe)']",
|
||||
"exec typeset 'a[$(scan_probe)]=1'",
|
||||
"a=(1); - typeset 'a[$(scan_probe)]=1'",
|
||||
"a=(1); exec unset 'a[$(scan_probe)]'",
|
||||
"a=(1); - unset 'a[$(scan_probe)]'",
|
||||
"a=(1); exec -a foo unset 'a[$(scan_probe)]'",
|
||||
"a=(1); exec let 'a[$(scan_probe)]'",
|
||||
"a=(1); - let 'a[$(scan_probe)]'",
|
||||
"unset a=(e:'scan_probe':)",
|
||||
"unsetenv a=(e:'scan_probe':)",
|
||||
"a=(1); r='a[$(scan_probe)]'; declare -n r; r=1",
|
||||
"a=(1); r='a[$(scan_probe)]'; declare -n r; echo $r",
|
||||
"a=(1); r='a[$(scan_probe)]'; typeset -n r; r=1",
|
||||
"a=(1); x='a[$(scan_probe)]'; (( $'\\170' ))",
|
||||
"a=(1); x='a[$(scan_probe)]'; echo ${a[$'\\170']}",
|
||||
"a=(1); x='a[$(scan_probe)]'; declare a[x]=1",
|
||||
"a=(1); x='a[$(scan_probe)]'; declare 'a[x]=1'",
|
||||
"a=(1); x='a[$(scan_probe)]'; unset 'a[x]'",
|
||||
"a=(1); x='a[$(scan_probe)]'; [[ -v a[x] ]]",
|
||||
"a=(1); x='a[$(scan_probe)]'; read 'a[x]' <<< 1",
|
||||
"a=(1); x='a[$(scan_probe)]'; printf -v 'a[x]' 1",
|
||||
"a=(1); x='a[$(scan_probe)]'; test -v 'a[x]'",
|
||||
"a=(1); echo ${(s:):)a['a[$(scan_probe)]']}",
|
||||
"a=(1); s=abc; echo ${(s:):)s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); [[ !(-v 'a[$(scan_probe)]') ]]",
|
||||
"echo $'a\\'; scan_probe; ' \\'",
|
||||
"echo $'\\'$(scan_probe)' \\'",
|
||||
": <<$'a\\'; scan_probe; '\\'\n$a\\\n\na'; scan_probe; '",
|
||||
"export X=$'\\c\\''; scan_probe # '",
|
||||
"a=(1); declare $'a[\\0$(scan_probe)]=1'",
|
||||
"a=(1); unset $'a[\\0$(scan_probe)]'",
|
||||
"a=(1); declare $'a[\\u0000$(scan_probe)]=1'",
|
||||
": <<$'\\u0041'\n\\u0041\nscan_probe\nA",
|
||||
": <<$'\\U00000041'\n\\U00000041\nscan_probe\nA",
|
||||
"$'s\\can_probe'",
|
||||
"$'de\\clare' 'a[$(scan_probe)]=1'",
|
||||
"$'\\scan_probe'",
|
||||
"a=(1); typeset $'a\\[\\$\\(scan_probe\\)\\]=1'",
|
||||
"$'scan_probe\\xz'",
|
||||
"$'scan_probe\\uz'",
|
||||
"$'scan_probe\\Uz'",
|
||||
"$'scan_probe\\C-@x'",
|
||||
"$'scan_probe\\C@x'",
|
||||
"a=(1); set -A 'a[$(scan_probe)]' 1",
|
||||
"a=(1); set +A 'a[$(scan_probe)]' 1",
|
||||
"unsetopt noglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"set +o noglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"set -xo globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"emulate zsh -o globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"NULLCMD=scan_probe; >/dev/null",
|
||||
"READNULLCMD=scan_probe; </dev/null",
|
||||
"NULLCMD=scan_probe; <<< x",
|
||||
"setopt promptsubst; x='$(scan_probe)'; print -P $x",
|
||||
"setopt promptvars; print -P '$(scan_probe)'",
|
||||
"a=(1); exit 'a[$(scan_probe)]'",
|
||||
"f() { return 'a[$(scan_probe)]'; }; a=(1); f",
|
||||
"a=(1); shift 'a[$(scan_probe)]'",
|
||||
"a=(1); for x in 1; do break 'a[$(scan_probe)]'; done",
|
||||
"a=(1); for x in 1; do continue 'a[$(scan_probe)]'; done",
|
||||
"a=(1); bye 'a[$(scan_probe)]'",
|
||||
"a=(1); logout 'a[$(scan_probe)]'",
|
||||
"a=(1); getln 'a[$(scan_probe)]'",
|
||||
"a=(1); vared 'a[$(scan_probe)]'",
|
||||
"a=(1); zformat -f 'a[$(scan_probe)]' x",
|
||||
"a=(1); zstyle -s : x 'a[$(scan_probe)]'",
|
||||
"zmodload zsh/stat; a=(1); zstat -A 'a[$(scan_probe)]' .",
|
||||
"a=(1); compgen -V x -W 'a[$(scan_probe)]'; echo $((x[0]))",
|
||||
|
||||
// Special/positional subscripts, escape decoders, split expansions, float/array declarations, and continuations.
|
||||
"a=(1); set -- 1 2 3 4 5 6 7 8 9 abc; echo ${1\\\n0:'a[$(scan_probe)0]'}",
|
||||
"a=(1); echo ${1\\\n0[1,'a[$(scan_probe)]']}",
|
||||
"a=(1); echo $0[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $?[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $#[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $-[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $![1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $$[1,'a[$(scan_probe)]']",
|
||||
'a=(1); echo "$?[1,a[\\$(scan_probe)]]"',
|
||||
'a=(1); echo "$$[1,a[\\$(scan_probe)]]"',
|
||||
"a=(1); x='a[\\x24(scan_probe)]'; unset \"${x@E}\"",
|
||||
"a=(1); x='a[\\x24(scan_probe)]'; declare \"${x@E}=1\"",
|
||||
"a=(1); x='a[\\x24(scan_probe)]'; y=${x@E}; echo $((y))",
|
||||
'a=(1); x="$\'a[\\\\x24(scan_probe)]\'"; unset "${(Q)x}"',
|
||||
'a=(1); x="$\'a[\\\\x24(scan_probe)]\'"; typeset "${(Q)x}=1"',
|
||||
"a=(1); x=\"$'a[\\\\x24(scan_probe)]'\"; y=${(Q)x}; echo $((y))",
|
||||
"a=(1); x='a[$'; x+='(scan_probe)]'; unset \"$x\"",
|
||||
'a=(1); d=\'$\'; x="a[${d}(scan_probe)]"; unset "$x"',
|
||||
"a=(1); x='a[$_1(scan_probe)]'; unset \"${x/_1/}\"",
|
||||
"a=(1); x='a[$_1(scan_probe)]'; echo $(( ${x/_1/} ))",
|
||||
"a=(1); x='a[$'; y='(scan_probe)]'; echo $(( $x$y ))",
|
||||
"a=(1); read x <<< 'a[$\\(scan_probe)]'; echo $((x))",
|
||||
"a=(1); printf -v x 'a[\\x24(scan_probe)]'; echo $((x))",
|
||||
"a=(1); print -v x 'a[\\x24(scan_probe)]'; echo $((x))",
|
||||
"a=(1); typeset -E x; x='a[$(scan_probe)]'",
|
||||
"a=(1); typeset -F x; x='a[$(scan_probe)]'",
|
||||
"a=(1); y='a[$(scan_probe)]'; typeset -E x=y",
|
||||
"a=(1); y='a[$(scan_probe)]'; typeset -F x=y",
|
||||
"a=(); declare a='([$(scan_probe)]=1)'",
|
||||
"a=(); typeset a='([$(scan_probe)]=1)'",
|
||||
"a=(); declare a+='([$(scan_probe)]=1)'",
|
||||
"f() { local -a a; local a='([$(scan_probe)]=1)'; }; f",
|
||||
"declare -a a; x='([$(scan_probe)]=1)'; declare a=$x",
|
||||
"x=${NULLCMD::=scan_probe}; >/dev/null",
|
||||
"x=${READNULLCMD::=scan_probe}; </dev/null",
|
||||
": a${NULLCMD::=scan_probe}; >/dev/null",
|
||||
": ${^NULLCMD::=scan_probe}; >/dev/null",
|
||||
": ${(f)NULLCMD::=scan_probe}; >/dev/null",
|
||||
"print -vNULLCMD scan_probe; >/dev/null",
|
||||
"(( ${NULLCMD::=scan_probe} )); >/dev/null",
|
||||
"[[ -n ${NULLCMD::=scan_probe} ]]; >/dev/null",
|
||||
"a=(${NULLCMD::=scan_probe}); >/dev/null",
|
||||
": <<EOF\n${NULLCMD::=scan_probe}\nEOF\n>/dev/null",
|
||||
"PS4='$(scan_probe)'; set -x; x=1",
|
||||
"PS4='$(scan_probe)'; set -o xtrace; x=1",
|
||||
"export PS4='$(scan_probe)'; set -x; x=1",
|
||||
"setopt -m 'glob*'; x='*(e:scan_probe:)'; echo $x",
|
||||
"setopt -m '*subst*'; x='*(e:scan_probe:)'; echo $x",
|
||||
"emulate sh; unsetopt shglob; setopt bareglobqual; x='*(e:scan_probe:)'; echo $x",
|
||||
"emulate ksh; unsetopt shglob; setopt bareglobqual; x='*(e:scan_probe:)'; echo $x",
|
||||
"a=(1); zregexparse 'a[$(scan_probe)]' 'a[$(scan_probe)]' x /x/",
|
||||
"a=(1); private 'a[$(scan_probe)]=1'",
|
||||
"a=(1); [[ x =~ (a )(b)]] || 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ x =~ (a )(b)]] || -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ -t 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); x='a[$(scan_probe)]'; [[ -t x ]]",
|
||||
"a=(1); x='a[$(scan_probe)]'; [[ -t $x ]]",
|
||||
"a=(1); test -t 'a[$(scan_probe)]'",
|
||||
"a=(1); [ -t 'a[$(scan_probe)]' ]",
|
||||
"a=(1); x='a[$(scan_probe)]'; test -t x",
|
||||
"a=(1); x='a[$(scan_probe)]'; [ -t x ]",
|
||||
"echo $\\\n'a\\''; scan_probe; : \\'",
|
||||
": <<$\\\n'EOF'\nEOF\nscan_probe\n$EOF",
|
||||
': <<$\\\n"EOF"\nEOF\nscan_probe\n$EOF',
|
||||
"$\\\n'scan_probe'",
|
||||
'$\\\n"scan_probe"',
|
||||
"a=(1); unset $\\\n'a[\\x24(scan_probe)]'",
|
||||
"echo \"${x:-$\\\n'\\x24(scan_probe)'}\"",
|
||||
"[[ -n x ]\\\n] && scan_probe && [[ -n y ]]",
|
||||
"a=(1); (\\\n( 'a[$(scan_probe)]' ))",
|
||||
"a=(1); (\\\n( 1 + 'a[$(scan_probe)]' ))",
|
||||
"a=(1); echo $(\\\n( 'a[$(scan_probe)]' ))",
|
||||
"a=(1); x='a[$(scan_probe)]'; echo $(( x )\\\n)",
|
||||
"a=(1); [\\\n[ -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [\\\n[ 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a[0]\\\n=1 scan_probe",
|
||||
"a[0]+\\\n=1 scan_probe",
|
||||
"a=(); a[0]\\\n='a[$(scan_probe)]'; echo $((a[0]))",
|
||||
"x='x y'; scan_probe[$x]\\\n=1",
|
||||
"a=(1); x='a[$_1]'; y='(scan_probe)'; unset \"${x/_1/$y}\"",
|
||||
"a=(1); x='a[$_1]'; y='(scan_probe)'; echo $(( ${x/_1/$y} ))",
|
||||
"a=(1); x='a[$1'; y='(scan_probe)]'; echo $(( ${x%1}$y ))",
|
||||
"a=(1); x='a[$1\n(scan_probe)]'; unset \"${x/1?/}\"",
|
||||
"printf -v x 'a[\\x24\\x28scan_probe\\x29]'; echo $((x))",
|
||||
"printf -v x 'a[\\044\\050scan_probe\\051]'; echo $((x))",
|
||||
"a=(1); printf -v x 'a[\\u0024(scan_probe)]'; echo $((x))",
|
||||
"a=(1); printf -v x 'a[\\U00000024(scan_probe)]'; echo $((x))",
|
||||
"typeset -a a; print -v x 'a[\\x24\\x28scan_probe\\x29]'; echo $((x))",
|
||||
"typeset -a a; print -v x 'a[\\044\\050scan_probe\\051]'; echo $((x))",
|
||||
"typeset -a a; print -v x 'a[\\u0024\\u0028scan_probe\\u0029]'; echo $((x))",
|
||||
"typeset -a a; print -v x 'a[\\U00000024\\U00000028scan_probe\\U00000029]'; echo $((x))",
|
||||
'a=(1); x="$\'a[\\\\x24\\\\x28scan_probe\\\\x29]\'"; unset "${x:Q}"',
|
||||
'a=(1); x="$\'a[\\\\x24\\\\x28scan_probe\\\\x29]\'"; unset "$x:Q"',
|
||||
"a=(1); x=\"$'a[\\\\x24\\\\x28scan_probe\\\\x29]'\"; y=${x:Q}; echo $((y))",
|
||||
"a=(1); read x <<'EOF'\na[$(scan_probe)]\nEOF\necho $((x))",
|
||||
"a=(1); read x <<EOF\na[\\$(scan_probe)]\nEOF\necho $((x))",
|
||||
"mapfile -t a <<'EOF'\nb[$(scan_probe)]\nEOF\necho $((a[0]))",
|
||||
"a=(); declare a=\"$(echo '([$(scan_probe)]=1)')\"",
|
||||
"a=(); declare a=\"`echo '([$(scan_probe)]=1)'`\"",
|
||||
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$1",
|
||||
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$@",
|
||||
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$*",
|
||||
"a=(); set -- '([$(scan_probe)]=1)'; declare a=\"$1\"",
|
||||
"x==(scan_probe)",
|
||||
"x+==(scan_probe)",
|
||||
"a=(1); a[1]==(scan_probe)",
|
||||
"a=(1); a[1]+==(scan_probe)",
|
||||
"export x==(scan_probe)",
|
||||
"typeset x==(scan_probe)",
|
||||
"f() { local x==(scan_probe); }; f",
|
||||
"x==\\\n(scan_probe)",
|
||||
"a=(1); ref='a[$(scan_probe)]'; echo ${\\\n!ref}",
|
||||
"a=(1); ref='a[$(scan_probe)]'; echo \"${\\\n!ref}\"",
|
||||
"a=(1); set -k; f() { echo $((x)); }; f x='a[$(scan_probe)]'",
|
||||
"a=(1); set -o keyword; f() { echo $((x)); }; f x='a[$(scan_probe)]'",
|
||||
"a=(1); set -k; test -v 'a[x]' x='a[$(scan_probe)]'",
|
||||
"a=(1); set -o posix -k; : x='a[$(scan_probe)]'; echo $((x))",
|
||||
"set -oglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"set +onoglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"setopt -oglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"setopt +onoglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"printf -vPS4 '\\x24(scan_probe)'; set -x; x=1",
|
||||
"printf -vPS4 '\\044(scan_probe)'; set -x; x=1",
|
||||
"functions[zshexit]=scan_probe",
|
||||
"functions[TRAPEXIT]=scan_probe",
|
||||
"functions[TRAPDEBUG]=scan_probe; x=1",
|
||||
"functions=(zshexit scan_probe)",
|
||||
": ${functions[zshexit]::=scan_probe}",
|
||||
"functions[echo]=scan_probe; echo hi",
|
||||
"options[globsubst]=on; x='*(e:scan_probe:)'; echo $x",
|
||||
"options=(globsubst on); x='*(e:scan_probe:)'; echo $x",
|
||||
": ${options[globsubst]::=on}; x='*(e:scan_probe:)'; echo $x",
|
||||
"aliases[echo]=scan_probe; options[aliases]=on; : `echo`",
|
||||
"galiases[echo]=scan_probe; options[aliases]=on; : `echo`",
|
||||
"saliases[x]=scan_probe; options[aliases]=on; : `a.x`",
|
||||
"commands[ls]=$(command -v scan_probe); ls",
|
||||
"a=(1); [[ x == \"\" || ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x != '' && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x != '' && ( -v 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x != '' && ( -t 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ == && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ -n == && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x == != || ( -t 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x == == || ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ ( -n == ) && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"[[ -n $$'\\'' ]]; scan_probe # ' ]]",
|
||||
"a=($$'\\''); scan_probe # ')",
|
||||
"echo ${x:-$$'\\''}; scan_probe # '}",
|
||||
"export X=$$\\\n'\\''; scan_probe # '",
|
||||
"export X=$'\\'; scan_probe; : '; cat <<E\n'\nE",
|
||||
"export X=$\\\n$'\\'; scan_probe; : ' # '",
|
||||
'a=(1); echo "$a[(r)\\],a[\\$(scan_probe)]]"',
|
||||
'a=(1); echo "$?[1\\],a[\\$(scan_probe)]]"',
|
||||
'export X="${unset:+${a[0}}"; scan_probe; : "]}}"',
|
||||
"export X=${unset:+${a[0}}; scan_probe; : ]}}",
|
||||
'export X=${unset:+${a["0}"}}; scan_probe; : ]}}',
|
||||
'cat <<E; export X="${unset:+$(( 0 + "0 ))}" && scan_probe\n))}"\nE',
|
||||
'false && export X="${unset:+$(( 0 }"; scan_probe; : "))}"',
|
||||
'false && export X="${unset:+$(echo a}"; scan_probe; : ")}"',
|
||||
'false && export X="${unset:+$(echo {a}"; scan_probe; : ")}"',
|
||||
'false && export X="${unset:+$(echo a})}"; scan_probe; : "}"',
|
||||
"export X=$$'\\' # '; scan_probe",
|
||||
"export X=$$'\\' #\nexport Y='; scan_probe # '",
|
||||
"export X=$^$'\\' # '; scan_probe",
|
||||
"export X=$#$'\\' # '; scan_probe",
|
||||
"export X=$'\\'\nscan_probe\n'",
|
||||
] as const
|
||||
|
||||
// These also run inside every wrapper below.
|
||||
const nestedFixtures = [
|
||||
"[[ a]]# ]] && scan_probe",
|
||||
"{ export X={}# ; scan_probe; }",
|
||||
"unset a[b; scan_probe; echo ]=1",
|
||||
"case esac in (esac) scan_probe;; esac",
|
||||
"case esac in (a|esac) scan_probe;; esac",
|
||||
"for i in 1; { if true; then scan_probe; fi }; while false; do export X=1; done",
|
||||
"(( echo '\"' ); scan_probe; ( echo '\"' ))",
|
||||
"export X=${unset:+${x['0\"0']}}; scan_probe; : '\"]}}' # '",
|
||||
"export X=$$'\\'; scan_probe # '",
|
||||
": <<-export\n\tex\\\n\tport\n# $(scan_probe)\nexport",
|
||||
"!(scan_probe)",
|
||||
"f+() case x in x) scan_probe;; esac; f+",
|
||||
"f@g() case x in x) scan_probe;; esac; f@g",
|
||||
"f@g() for i in 1; do scan_probe; done; f@g",
|
||||
] as const
|
||||
|
||||
const wrappers: Array<[name: string, wrap: (inner: string) => string]> = [
|
||||
["$(...)", (inner) => `export OUTER=$( ${inner}\n)`],
|
||||
['"$(...)"', (inner) => `export OUTER="$( ${inner}\n)"`],
|
||||
["backticks", (inner) => `export OUTER=\` ${inner}\n\``],
|
||||
["heredoc", (inner) => `: <<EOF\n$( ${inner}\n)\nEOF`],
|
||||
["case arm", (inner) => `case x in x) ${inner}\n;; esac`],
|
||||
["for loop", (inner) => `for k in 1; do ${inner}\ndone`],
|
||||
["function", (inner) => `wrap_fn() {\n${inner}\n}; wrap_fn`],
|
||||
["brace group", (inner) => `{ ${inner}\n}`],
|
||||
]
|
||||
|
||||
describe.skipIf(process.platform === "win32")("real-shell soundness oracle", () => {
|
||||
test("discovers at least bash on PATH", () => {
|
||||
expect(shells.some((item) => item.endsWith("/bash"))).toBe(true)
|
||||
})
|
||||
|
||||
test.each([...fixtures, ...nestedFixtures])("reports or rejects real-shell execution: %j", (source) => {
|
||||
expectProbesReported(source)
|
||||
})
|
||||
|
||||
test.each(wrappers.flatMap(([name, wrap]) => nestedFixtures.map((source) => [name, source, wrap(source)])))(
|
||||
"reports or rejects in %s: %j",
|
||||
(_, __, source) => {
|
||||
expectProbesReported(source)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === "win32")("real-shell directory oracle", () => {
|
||||
test.each(["cd >/dev/null TARGET", "cd 2>/dev/null TARGET", "cd $'TARGET'", 'cd "TARGET"'])(
|
||||
"reports the directory a real shell changes to: %s",
|
||||
async (template) => {
|
||||
const source = template.replace("TARGET", target)
|
||||
const targets = [target, fs.realpathSync(target)]
|
||||
const changed = shells.filter((executable) =>
|
||||
targets.includes(observe(executable, `${source}\npwd >> "$SCAN_PROBE_LOG"`).at(-1) ?? ""),
|
||||
)
|
||||
expect(changed.length, `Fixture never changed directory in any real shell: ${source}`).toBeGreaterThan(0)
|
||||
for (const executable of changed) {
|
||||
const parsed = await Effect.runPromiseExit(ShellParse.scanPortable(source, executable, root))
|
||||
if (Exit.isSuccess(parsed)) expect(parsed.value.directories, `${executable} in: ${source}`).toContain(target)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -38,11 +38,6 @@ describe("Bash redirect resource oracle", () => {
|
||||
`${redirect} FOO=bar git status 3>tail`,
|
||||
`npm run ${redirect} test`,
|
||||
]) {
|
||||
// Dash reads `&>` as `&` and `>`, so words after its target start another command there.
|
||||
if (redirect.startsWith("&") && !command.endsWith(redirect)) {
|
||||
expect(ShellScan.scan(command).kind).toBe("opaque")
|
||||
continue
|
||||
}
|
||||
await parity(command)
|
||||
for (const separator of separators) {
|
||||
await parity(`printf ok${separator}${command}`)
|
||||
@@ -85,6 +80,7 @@ describe("Bash redirect resource oracle", () => {
|
||||
"pwd | cat 2\\>out",
|
||||
"if true; then printf ok && cat >$(printf path); fi",
|
||||
"if true; then printf ok && git >out status; else cat >log; fi",
|
||||
"pwd && cd >out /outside",
|
||||
"time git status",
|
||||
"time -p git status",
|
||||
"coproc git status",
|
||||
@@ -155,12 +151,4 @@ describe("Bash redirect resource oracle", () => {
|
||||
{ resource: "FOO=bar >output git status", save: "git status *" },
|
||||
])
|
||||
})
|
||||
|
||||
test("known gap: redirect before cd operand retains the target directory natively", async () => {
|
||||
const source = "pwd && cd >out /outside"
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
|
||||
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
|
||||
expect(legacy).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: [] })
|
||||
expect(native).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: ["/outside"] })
|
||||
})
|
||||
})
|
||||
@@ -217,8 +217,6 @@ describe("ShellScan", () => {
|
||||
["CDPATH=/usr # comment\ncd bin; rm victim", ["cd", "rm"]],
|
||||
["HOME=/etc # comment\ncd; rm victim", ["cd", "rm"]],
|
||||
["VALUE=$(printf 2); echo $((VALUE + 1))", ["printf", "echo"]],
|
||||
["MSG='Use ${FOO} here'; some_cmd || exit $?", ["some_cmd", "exit"]],
|
||||
["f() { local REGEX='[0-9]+${FOO}'; return $?; }", ["local", "return"]],
|
||||
] as const)("scans assignment-only boundaries without evaluating their effects: %s", (command, names) => {
|
||||
const result = ShellScan.scan(command)
|
||||
expect(result.kind).toBe("scanned")
|
||||
@@ -236,6 +234,8 @@ describe("ShellScan", () => {
|
||||
"{fd}>/tmp/log touch /tmp/victim",
|
||||
"time touch /tmp/victim",
|
||||
"printf '%s' \"$(printf safe ${x%)}; touch /tmp/victim)\"",
|
||||
"s=abc; x='a[$(touch /tmp/victim)0]'; printf '%s' \"${s:x}\"",
|
||||
"ref='x[$(touch /tmp/victim)0]'; printf '%s' \"${!ref}\"",
|
||||
"if true; then echo safe; fi > /tmp/victim",
|
||||
"if true; then :; 'if' victim; fi",
|
||||
])("scans Bash lexical forms without interpreting shell values: %s", (command) => {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# `@opencode/plugin`
|
||||
|
||||
Authoring interfaces and runtime loader support for OpenCode V2 plugins:
|
||||
|
||||
- `@opencode/plugin` — [Promise plugin API](./src/README.md)
|
||||
- `@opencode/plugin/effect` — [Effect plugin API](./src/effect/README.md)
|
||||
- `@opencode/plugin/rpc` — portable RPC contract definitions
|
||||
- `@opencode/plugin/tui` — terminal UI plugin API
|
||||
|
||||
## Packaging And Runtime `effect`
|
||||
|
||||
When the OpenCode CLI loads server or TUI plugins, it resolves imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` entrypoints (including imports from dependencies inside a plugin's `node_modules`) to the host's runtime module instances so fibers, loggers, and `Schema` parsers share one copy.
|
||||
|
||||
- Declare `effect` as a `peerDependency` (and `devDependency` for local type-checking and testing) rather than a bundled runtime dependency.
|
||||
- Do not bundle `effect` into published plugin files; if you build with a bundler, keep `effect` and `effect/*` external. Two copies of `effect` do not share fiber, logger, or `Schema` internals.
|
||||
- Plugins and their `node_modules` dependencies always receive OpenCode's host `effect` instance. Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another `effect` major (such as Effect 3) are not supported.
|
||||
@@ -7,11 +7,6 @@ import { localSource } from "./source.js"
|
||||
import { missingPackageTarget } from "./source.package.js"
|
||||
|
||||
let generation = Date.now()
|
||||
const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
|
||||
|
||||
type GlobalState = typeof globalThis & {
|
||||
[runtimeModulesKey]?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const root = fileURLToPath(entrypoint)
|
||||
@@ -44,7 +39,6 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
|
||||
? new URL(item.path, pathToFileURL(file))
|
||||
: localSource(item.path, path.dirname(file))
|
||||
if (!local) {
|
||||
if ((globalThis as GlobalState)[runtimeModulesKey]?.[item.path]) continue
|
||||
try {
|
||||
Bun.resolveSync(item.path, path.dirname(file))
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
@@ -11,7 +11,7 @@ const source = 'throw new Error("Plugin code must not run during resolution")'
|
||||
const name = "@fixture/plugin"
|
||||
|
||||
async function fixture(files: Record<string, string>, installed = false) {
|
||||
const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-")))
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-host-"))
|
||||
const directory = installed ? path.join(root, "node_modules", name) : root
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([file, content]) => {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Temporary diff color tuning output. Generated by diff-color-review-artifacts/tuner; delete with the tuner.
|
||||
export const diffColorTuningCSS = ``
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { ComponentProps } from "solid-js"
|
||||
import { lineCommentStyles } from "../components/line-comment-styles"
|
||||
import { diffColorTuningCSS } from "./diff-color-tuning"
|
||||
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T, undefined> & {
|
||||
before: FileContents
|
||||
@@ -22,19 +23,29 @@ const unsafeCSS = `
|
||||
color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-mixer)),
|
||||
color-mix(in lab, var(--diffs-bg) 95%, var(--diffs-mixer))
|
||||
);
|
||||
--diffs-bg-addition-emphasis-override: light-dark(
|
||||
rgb(from var(--v2-green-600) r g b / 0.2),
|
||||
rgb(from var(--v2-green-600) r g b / 0.4)
|
||||
);
|
||||
--diffs-bg-deletion-emphasis-override: light-dark(
|
||||
rgb(from var(--v2-red-600) r g b / 0.2),
|
||||
rgb(from var(--v2-red-600) r g b / 0.4)
|
||||
);
|
||||
--diffs-fg-number-override: var(--v2-text-text-faint);
|
||||
--diffs-fg-number-addition-override: light-dark(var(--v2-green-900), var(--v2-green-400));
|
||||
--diffs-fg-number-deletion-override: light-dark(var(--v2-red-800), var(--v2-red-500));
|
||||
}
|
||||
|
||||
[data-diff],
|
||||
[data-file] {
|
||||
/* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */
|
||||
--diffs-bg-deletion-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base))
|
||||
rgb(from var(--v2-red-600) r g b / 0.05),
|
||||
rgb(from var(--v2-red-600) r g b / 0.1)
|
||||
);
|
||||
--diffs-bg-deletion-number-override: var(--diffs-bg-deletion-override);
|
||||
--diffs-bg-addition-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base))
|
||||
rgb(from var(--v2-green-600) r g b / 0.05),
|
||||
rgb(from var(--v2-green-800) r g b / 0.1)
|
||||
);
|
||||
--diffs-bg-addition-number-override: var(--diffs-bg-addition-override);
|
||||
--diffs-selection-base: var(--v2-background-bg-accent);
|
||||
@@ -56,9 +67,13 @@ const unsafeCSS = `
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
[data-indicators='bars'] [data-column-number][data-line-type='change-addition']::before {
|
||||
background-color: light-dark(var(--v2-green-900), var(--v2-green-400));
|
||||
}
|
||||
|
||||
[data-indicators='bars'] [data-column-number][data-line-type='change-deletion']::before {
|
||||
background-image: none;
|
||||
background-color: var(--diffs-deletion-base);
|
||||
background-color: light-dark(var(--v2-red-800), var(--v2-red-500));
|
||||
}
|
||||
|
||||
[data-background] [data-column-number] {
|
||||
@@ -66,6 +81,35 @@ const unsafeCSS = `
|
||||
--mix-dark: 80%;
|
||||
}
|
||||
|
||||
/* Changed-row overrides are final translucent fills, so Pierre must not blend them into the background again. */
|
||||
[data-background]
|
||||
:is([data-line], [data-no-newline], [data-column-number], [data-gutter-buffer]):is(
|
||||
[data-line-type='change-addition'],
|
||||
[data-line-type='change-deletion']
|
||||
) {
|
||||
--mix-light: 0%;
|
||||
--mix-dark: 0%;
|
||||
}
|
||||
|
||||
/* Syntax colors are inline styles on token spans, so emphasized text needs !important to stay legible on its highlight. */
|
||||
[data-line-type='change-addition'] [data-diff-span],
|
||||
[data-line-type='change-addition'] [data-diff-span] * {
|
||||
color: light-dark(var(--v2-green-1200), var(--v2-text-text-base)) !important;
|
||||
}
|
||||
|
||||
[data-line-type='change-deletion'] [data-diff-span],
|
||||
[data-line-type='change-deletion'] [data-diff-span] * {
|
||||
color: light-dark(var(--v2-red-1200), var(--v2-text-text-base)) !important;
|
||||
}
|
||||
|
||||
:is([data-separator-content], [data-expand-button]) {
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
[data-expand-button]:hover {
|
||||
color: var(--diffs-fg);
|
||||
}
|
||||
|
||||
[data-diff-type='split'] [data-additions],
|
||||
[data-diff-type='split'] [data-additions] [data-gutter],
|
||||
[data-diff-type='split'] [data-deletions],
|
||||
@@ -201,6 +245,7 @@ const unsafeCSS = `
|
||||
|
||||
${lineCommentStyles}
|
||||
|
||||
${diffColorTuningCSS}
|
||||
`
|
||||
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T, undefined>["diffStyle"]) {
|
||||
|
||||
@@ -1094,6 +1094,7 @@ body {
|
||||
[data-page="stats"] [data-component="top-models-chart"] {
|
||||
--top-models-bar-gap: 12px;
|
||||
--top-models-dot-size: 6px;
|
||||
--top-models-dot-offset: 2px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 34px minmax(0, 1fr);
|
||||
@@ -1223,31 +1224,38 @@ body {
|
||||
padding-inline: calc(var(--top-models-bar-gap) / 2);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
|
||||
--top-models-bar-inset: calc((100% - 6px) / 6);
|
||||
}
|
||||
|
||||
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
|
||||
[data-page="stats"] [data-slot="top-models-bar"]::before {
|
||||
position: absolute;
|
||||
inset: 0 var(--top-models-bar-inset);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: calc(100% - var(--top-models-bar-gap));
|
||||
transform: translateX(-50%);
|
||||
content: "";
|
||||
background: var(--stats-dot);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
mask-position: left top;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: var(--top-models-dot-offset) top;
|
||||
mask-repeat: repeat;
|
||||
mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: left top;
|
||||
mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: var(--top-models-dot-offset) top;
|
||||
-webkit-mask-repeat: repeat;
|
||||
-webkit-mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
|
||||
-webkit-mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
|
||||
}
|
||||
|
||||
@supports (width: round(down, 100%, 1px)) {
|
||||
[data-page="stats"] [data-slot="top-models-bar"]::before {
|
||||
width: round(down, calc(100% - var(--top-models-bar-gap)), var(--top-models-dot-size));
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="top-models-stack"] {
|
||||
position: absolute;
|
||||
right: var(--top-models-bar-inset);
|
||||
right: calc(var(--top-models-bar-gap) / 2);
|
||||
bottom: 0;
|
||||
left: var(--top-models-bar-inset);
|
||||
left: calc(var(--top-models-bar-gap) / 2);
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
height: var(--top-models-bar-height);
|
||||
@@ -4537,6 +4545,7 @@ body {
|
||||
[data-page="stats"] [data-component="model-usage-chart"] {
|
||||
--model-usage-bar-gap: 6px;
|
||||
--model-usage-dot-size: 6px;
|
||||
--model-usage-dot-offset: 2px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 40px minmax(0, 1fr);
|
||||
@@ -4594,31 +4603,38 @@ body {
|
||||
padding-inline: calc(var(--model-usage-bar-gap) / 2);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
|
||||
--model-usage-bar-inset: calc((100% - 6px) / 6);
|
||||
}
|
||||
|
||||
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
|
||||
[data-page="stats"] [data-slot="model-usage-column"]::before {
|
||||
position: absolute;
|
||||
inset: 0 var(--model-usage-bar-inset);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: calc(100% - var(--model-usage-bar-gap));
|
||||
transform: translateX(-50%);
|
||||
content: "";
|
||||
background: var(--stats-dot);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
mask-position: left top;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: var(--model-usage-dot-offset) top;
|
||||
mask-repeat: repeat;
|
||||
mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: left top;
|
||||
mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: var(--model-usage-dot-offset) top;
|
||||
-webkit-mask-repeat: repeat;
|
||||
-webkit-mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
|
||||
-webkit-mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
|
||||
}
|
||||
|
||||
@supports (width: round(down, 100%, 1px)) {
|
||||
[data-page="stats"] [data-slot="model-usage-column"]::before {
|
||||
width: round(down, calc(100% - var(--model-usage-bar-gap)), var(--model-usage-dot-size));
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-bar"] {
|
||||
position: absolute;
|
||||
right: var(--model-usage-bar-inset);
|
||||
right: calc(var(--model-usage-bar-gap) / 2);
|
||||
bottom: 0;
|
||||
left: var(--model-usage-bar-inset);
|
||||
left: calc(var(--model-usage-bar-gap) / 2);
|
||||
z-index: 1;
|
||||
height: var(--model-usage-fill);
|
||||
min-height: 0;
|
||||
@@ -8180,8 +8196,7 @@ body {
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="model-usage-chart"] {
|
||||
/* 18px puts three 2px dot columns 6px apart, matching the 6px bar gap. */
|
||||
--model-usage-mobile-bar-width: 18px;
|
||||
--model-usage-mobile-bar-width: 16px;
|
||||
--model-usage-mobile-edge-space: 48px;
|
||||
--model-usage-mobile-track-width: calc(
|
||||
var(--model-usage-count) * (var(--model-usage-mobile-bar-width) + var(--model-usage-bar-gap)) +
|
||||
@@ -8222,7 +8237,7 @@ body {
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-column"]::before {
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-bar"] {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui"
|
||||
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
|
||||
import { ensurePluginRuntime } from "../../../cli/src/plugin-runtime"
|
||||
|
||||
ensurePluginRuntime()
|
||||
ensureRuntimePluginSupport({
|
||||
additional: {
|
||||
"@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
|
||||
|
||||