diff --git a/.claude/commands/audit-spec.md b/.claude/commands/audit-spec.md new file mode 100644 index 000000000..65c4360c0 --- /dev/null +++ b/.claude/commands/audit-spec.md @@ -0,0 +1,428 @@ +--- +description: Audit a spec sheet for completeness, correctness, and internal consistency against plan and codebase +argument-hint: +allowed-tools: Read, Glob, Grep, Write, Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Bash(wc:*), Bash(date:*), Agent +--- + +# Spec Audit + +You are a senior software architect and QA lead with 15 years of experience auditing technical specifications for production systems. You approach every spec as an adversary — your job is to find every gap, contradiction, and untestable criterion before implementation begins. You are precise, concrete, and never rubber-stamp. + +**Spec document**: $1 + +## Validation + +If `$1` is empty, output: + +``` +Usage: /audit-spec +Example: /audit-spec docs/specs/agent-browser-integration-spec.md +``` + +Stop execution. + +## Context + +- Current branch: !`git branch --show-current` +- This is a **Tauri 2 + React 19** production application. One bug can break user projects. The spec must be airtight. + +## Critical Rules + +These override everything else. Internalize before proceeding. + +1. **Read the actual source code.** Do not trust the spec's claims about existing code. Verify every one. The spec was likely written from a plan, not from the codebase. Drift is expected and must be caught. +2. **Be adversarial.** Assume the spec has errors until proven otherwise. Your job is to find what's wrong, not confirm it's right. +3. **No rubber-stamping.** APPROVED means you verified every claim and found zero critical issues. If you can't verify something, flag it as a gap — don't assume it's correct. +4. **Be concrete.** Every finding must reference specific sections, line numbers, or file paths. "The error handling seems incomplete" is NOT a finding. "Section 3.4 defines behavior for `browser_click` timeout but Section 7 Error Taxonomy has no corresponding error message" IS a finding. +5. **Cross-reference exhaustively.** The most dangerous bugs come from two sections that each look correct in isolation but contradict each other. Check every number, name, and behavioral claim across all sections. +6. **Testability is non-negotiable.** Every acceptance criterion must have a clear input, action, and expected output. If you can't describe the test, the AC is unverifiable. + +## Severity Definitions + +| Severity | Meaning | Action | +| ----------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | +| **CRITICAL** | Spec is wrong, contradicts plan, contradicts codebase, or has behavior undefined for a likely scenario | Must fix before implementation begins | +| **INCONSISTENCY** | Spec contradicts itself between sections, or numbers/names don't match | Must fix — implementer will hit this and guess wrong | +| **GAP** | Behavior undefined for a scenario that will occur in production | Must define before implementation | +| **UNVERIFIABLE** | Acceptance criterion cannot be tested as written | Must rewrite to be testable | +| **DRIFT** | Spec claims something about existing code that is wrong or outdated | Must correct to match reality | +| **WARNING** | Ambiguity that could lead to misinterpretation, but has a reasonable default | Should clarify | +| **NOTE** | Observation, not a problem — something the implementer should be aware of | Informational | + +## Severity Examples + +Use these to calibrate your judgment: + + + +CRITICAL — spec contradicts codebase + +| # | Severity | Section | Finding | Evidence | Fix | +|---|----------|---------|---------|----------|-----| +| 1 | CRITICAL | 3.2 | Spec claims `BrowserTool` extends `BaseTool` with a `screenshot()` method, but `BaseTool` has no such method — it was removed in commit `a3f12bc` | `src/tools/base-tool.ts:45` — class has `execute()` and `validate()` only | Update spec to use `captureScreenshot()` from `ScreenshotMixin` (line 12 of `src/tools/mixins.ts`) | + + + + +GAP — undefined failure path + +| # | Scenario | What's missing | Suggested behavior | +|---|----------|---------------|-------------------| +| 1 | User closes browser tab while `browser_click` is in-flight | No defined behavior for mid-operation tab closure. Section 3.4 only covers timeout and element-not-found. | Should emit `tool:error` with `BrowserTabClosed` error, cancel pending operation, and update tool widget to "Tab closed" state. | + + + + +WARNING — ambiguity + +| # | Section | Ambiguity | Suggested clarification | +|---|---------|-----------|------------------------| +| 1 | 3.3 | "Results should be returned promptly" — no threshold defined. Does this mean <100ms? <1s? <5s? | Specify: "Results must be returned within 500ms. If the operation exceeds 500ms, emit a `tool:progress` event at 500ms intervals." | + + + + +--- + +## Step 0: Read the Spec and Check for Prior Audits + +Read the spec document at `$1`. If the spec document doesn't exist, output "Spec document not found at `$1`" and stop. + +Derive the audit output path: `reviews/audit-{spec-base-name}.md`. Check if a prior audit exists at that path. If it does, read it — increment the pass number and verify whether previous critical/inconsistency findings have been addressed. + +Extract and verify from the spec: + +1. **Companion plan path** — usually referenced in the header +2. **Feature scope** — what's being specified +3. **Section count and structure** — list all major sections +4. **Acceptance criteria count** — total ACs with priority breakdown +5. **Out-of-scope items** — what's explicitly excluded + +If a companion plan is referenced, read it completely. The plan is the **source of truth for implementation strategy**; the spec is the **source of truth for behavior**. They must not contradict. + +--- + +## Step 1: Codebase Ground-Truth + +The spec may make claims about existing code (current tool count, existing APIs, file paths, transport mechanisms, data structures). Every such claim must be verified. + +**For each claim about existing code:** + +1. Find the actual source file +2. Read the relevant section +3. Compare against the spec's claim +4. Flag any discrepancy as **DRIFT** + +**Minimum files to verify** (adjust based on the spec's scope): + +- Every file path mentioned in the spec +- Every existing API/function/type referenced +- Every "current state" or "baseline" claim +- Every tool/command name claimed to already exist + +**If a referenced file doesn't exist**: flag as **DRIFT** — the spec claims something about a file that was moved, renamed, or deleted. Search for the functionality elsewhere and note the correct path. + +**If the companion plan doesn't exist or isn't referenced**: proceed without plan cross-validation (Step 2). Note in the audit that plan alignment was not verified and mark the Plan Alignment grade as N/A. + +**Stop when you can answer:** + +- Does the spec accurately describe the current codebase state? +- Are all file paths, function names, and API surfaces correct? + +--- + +## Step 2: Plan-Spec Cross-Validation + +Compare the spec against its companion plan: + +### 2.1 Coverage Check + +- Every phase in the plan should map to at least one AC in the spec +- Every file the plan creates/modifies should have behavior defined in the spec +- Every edge case the plan addresses should have expected behavior in the spec + +**Flag as GAP**: Any plan phase with no corresponding spec coverage. +**Flag as CRITICAL**: Any spec AC that contradicts the plan's stated approach. + +### 2.2 Numerical Consistency + +- Tool counts: Does the spec's tool inventory match the plan's tool tables? +- File counts: Do the files-to-create/modify tables match? +- Timeout values: Do spec-stated timeouts match plan-stated timeouts? +- Size limits: Do transport limits, payload caps, etc. match? + +**Flag as INCONSISTENCY**: Any numerical mismatch. + +### 2.3 Naming Consistency + +- Tool names in spec must exactly match tool names in plan +- Schema names, type names, field names must be identical +- File paths must match +- State names (enum values, state machine states) must match + +**Flag as INCONSISTENCY**: Any naming mismatch. + +--- + +## Step 3: Internal Consistency Audit + +Check the spec against itself. Read each section and cross-reference against every other section. + +### 3.1 Acceptance Criteria vs Expected Behavior + +For every AC in Section 4 (or equivalent): + +- Is the behavior it tests actually defined in Section 3 (Expected Behavior)? +- Can you write a test for this AC using ONLY the information in the spec? If not, what's missing? +- Does the AC contradict any stated behavior? + +**Flag as UNVERIFIABLE**: ACs that can't be tested as written. +**Flag as INCONSISTENCY**: ACs that contradict stated behavior. + +### 3.2 Error Taxonomy vs Expected Behavior + +- Every error in the error taxonomy should be reachable from a scenario described in the spec +- Every failure mode in expected behavior should have a corresponding error +- Error messages should match between sections + +**Flag as GAP**: Errors with no trigger scenario. Failure modes with no error message. + +### 3.3 Data Contracts vs Usage + +- Every field in a data contract should be referenced somewhere in the spec (behavior, AC, or error) +- No "orphan" fields that exist in the contract but are never mentioned +- Types must be consistent (if epoch is `number` in one place, it can't be `string` elsewhere) + +**Flag as WARNING**: Orphan fields or type mismatches. + +### 3.4 Constraints vs Everything Else + +- Every constraint should be enforceable — there must be a mechanism described somewhere +- No AC should violate a stated constraint +- No expected behavior should violate a stated constraint + +**Flag as CRITICAL**: ACs or behavior that violate constraints. + +### 3.5 Out-of-Scope vs In-Scope + +- Nothing in the expected behavior or ACs should implement an out-of-scope item +- Nothing in the out-of-scope section should be required by an in-scope AC + +**Flag as CRITICAL**: Scope contradictions. + +--- + +## Step 4: Completeness Audit + +### 4.1 Happy Path Coverage + +For every use case in the spec: + +- Is the happy path fully specified from start to finish? +- Are all intermediate states defined? +- Is the final state verifiable? + +**Flag as GAP**: Incomplete happy paths. + +### 4.2 Failure Path Coverage + +For every use case: + +- What happens if step N fails? Is recovery defined for every step? +- What happens if the user interrupts the flow midway? +- What happens on timeout, disconnect, or crash at each step? + +**Flag as GAP**: Undefined failure paths. + +### 4.3 Boundary Conditions + +- Empty inputs (empty string, empty array, null, undefined, zero) +- Maximum inputs (largest payload, most elements, longest string) +- Concurrent operations (two tools at once, two sessions, rapid-fire calls) +- State transitions (what happens in each state if an unexpected event occurs?) + +**Flag as GAP**: Undefined boundary behavior. + +### 4.4 Cross-Feature Interactions + +- How does this feature interact with existing features? +- Does the spec address side effects on other systems (e.g., does browser snapshot affect terminal, file system, chat state)? +- Does the spec account for the feature running alongside other active features? + +**Flag as GAP**: Unaddressed cross-feature interactions. + +--- + +## Step 5: Testability Audit + +For every acceptance criterion: + +1. **Input**: What exactly do you provide? +2. **Action**: What exactly do you do? +3. **Expected output**: What exactly do you verify? + +If any of these three is ambiguous, the AC is unverifiable. + +Additionally check: + +- Are performance targets measurable with the tools described? +- Are success metrics collectible without additional instrumentation not in scope? +- Can the non-functional requirements be tested before production? + +**Flag as UNVERIFIABLE**: ACs or NFRs that can't be verified with the described approach. + +--- + +## Step 6: Write Audit Report + +Derive the output filename from `$1`. Example: `docs/specs/agent-browser-integration-spec.md` → `reviews/audit-agent-browser-integration-spec.md`. + +Write the audit to `reviews/audit-{spec-base-name}.md`: + +```markdown +# Spec Audit: [Spec Title] + +**Date**: [current date] +**Spec Document**: $1 +**Companion Plan**: [path if exists] +**Branch**: [branch name] +**Pass**: [N] (increment if previous audit exists, otherwise 1) + +## Spec Summary + +[2-3 sentences: What the spec defines, its scope, and overall completeness] + +## Audit Scope + +| Check | Files read | Claims verified | +| --------------------- | -------------------------- | ----------------------- | +| Codebase ground-truth | [N files] | [N claims] | +| Plan cross-validation | [yes/no] | [N checks] | +| Internal consistency | [N sections cross-checked] | [N cross-references] | +| Completeness | [N use cases, N ACs] | [N boundary conditions] | +| Testability | [N ACs evaluated] | [N verified testable] | + +## Verdict: [APPROVED / APPROVED WITH CHANGES / NEEDS REWORK] + +[1-2 sentence justification] + +- **APPROVED**: Zero critical, zero inconsistencies, zero gaps. Warnings and notes are acceptable. +- **APPROVED WITH CHANGES**: Zero critical. Has inconsistencies, gaps, or unverifiable ACs that are minor and have clear fixes listed below. +- **NEEDS REWORK**: Has critical findings, or too many gaps/inconsistencies to fix inline. + +**Grading:** + +| Dimension | Grade | Notes | +| ---------------------------------------- | ------------ | ----- | +| Correctness (claims match codebase) | [A-F] | | +| Plan alignment (spec covers plan fully) | [A-F or N/A] | | +| Internal consistency (no contradictions) | [A-F] | | +| Completeness (no gaps in behavior) | [A-F] | | +| Testability (all ACs are verifiable) | [A-F] | | +| Error coverage (all failures handled) | [A-F] | | + +## Findings + +### Critical (Must fix before implementation) + +| # | Severity | Section | Finding | Evidence | Fix | +| --- | -------- | ------- | -------------- | -------------------------------- | ------------ | +| 1 | CRITICAL | 3.2 | [What's wrong] | [File:line or section cross-ref] | [How to fix] | + +### Inconsistencies (Must fix — implementer will guess wrong) + +| # | Section A | Section B | Mismatch | Fix | +| --- | --------- | ---------- | -------------------- | ------------------ | +| 1 | 3.1 | Appendix A | [What doesn't match] | [Which is correct] | + +### Gaps (Behavior undefined for production scenario) + +| # | Scenario | What's missing | Suggested behavior | +| --- | ------------------------- | --------------------- | --------------------- | +| 1 | [When X happens during Y] | [No defined behavior] | [Proposed resolution] | + +### Unverifiable ACs (Must rewrite) + +| # | AC | Problem | Suggested rewrite | +| --- | ---- | ------------------------ | ------------------ | +| 1 | AC-N | [Why it can't be tested] | [Testable version] | + +### Drift (Spec claims don't match codebase) + +| # | Spec claim | Actual (from codebase) | File | Fix | +| --- | ------------------- | ---------------------- | -------------- | ----------- | +| 1 | "13 existing tools" | [actual count] | `path/to/file` | Update spec | + +### Warnings (Ambiguity that should be clarified) + +| # | Section | Ambiguity | Suggested clarification | +| --- | ------- | ---------------- | ----------------------- | +| 1 | 3.3 | [What's unclear] | [How to clarify] | + +### Notes (Informational — no action required) + +- [Observation 1] +- [Observation 2] + +## Codebase Verification Log + +| Spec claim | File checked | Verified? | Notes | +| ----------------- | ------------------- | --------- | --------------------- | +| [Claim from spec] | `path/to/file:line` | Yes/No | [Details if mismatch] | + +## Plan-Spec Alignment + +| Plan phase | Spec coverage | Gap? | +| ------------------- | ----------------- | ------------------ | +| Phase 1: Runtime | Section 3.1, AC-1 | No | +| Phase 2: MCP Server | Section 3.4, AC-5 | Partial — [detail] | + +## Required Before Implementation + +- [ ] Fix Critical #1: [description] +- [ ] Fix Inconsistency #1: [description] +- [ ] Define Gap #1: [description] +- [ ] Rewrite AC-N: [description] + +## Verdict Details + +### Correctness: [PASS / CONCERNS] + +[Details — are all claims about existing code accurate?] + +### Plan Alignment: [PASS / CONCERNS / N/A] + +[Details — does the spec fully cover the plan?] + +### Internal Consistency: [PASS / CONCERNS] + +[Details — does the spec contradict itself?] + +### Completeness: [PASS / CONCERNS] + +[Details — are all production scenarios defined?] + +### Testability: [PASS / CONCERNS] + +[Details — can every AC be verified?] + +### Error Coverage: [PASS / CONCERNS] + +[Details — is every failure mode handled?] +``` + +--- + +## Step 7: Output + +After writing file: + +1. Print: `Audit written to reviews/audit-{spec-base-name}.md` +2. Print the **Verdict**, **Grading table**, and **Required Before Implementation** sections +3. Print total finding counts: + +``` +Findings: X critical, X inconsistencies, X gaps, X unverifiable, X drift, X warnings, X notes +``` + +Read `$1` now. Do not explain what you're about to do — start the audit immediately. diff --git a/.claude/commands/changelog.md b/.claude/commands/changelog.md new file mode 100644 index 000000000..93a7c40e1 --- /dev/null +++ b/.claude/commands/changelog.md @@ -0,0 +1,155 @@ +--- +description: Generate a marketing-quality changelog for a release version +argument-hint: [pr-number] +allowed-tools: Bash(gh:*), Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Bash(git status:*), Read, Write, Edit, Glob, Grep +--- + +# Changelog Generator + +Generate a changelog for Orbit release `$1`, written for the marketing site. If a PR number `$2` is provided, pull its metadata from GitHub. + +## Validation + +If `$1` is empty, output: + +``` +Usage: /changelog [pr-number] +Examples: + /changelog v0.0.7 + /changelog v0.0.7 74 +``` + +Stop execution. + +## Step 0: Resolve Version + +The version is `$1`. Determine whether it's a version or a PR number: + +- **Pure integer** (e.g., `73`, `104`) → This is a PR number, not a version. Infer the version from the current branch name and use `$1` as the PR number. +- **Contains dots** (e.g., `0.0.7`, `v0.0.7`) → This is a version. Prepend `v` if missing. +- **Starts with `v`** (e.g., `v0.0.7`) → This is a version. Use as-is. + +To get the branch name for fallback: + +```bash +git branch --show-current +``` + +--- + +## Step 1: Gather Context + +Collect all raw material. Run these commands: + +```bash +git branch --show-current +git log main..HEAD --oneline +git diff main --stat +git diff main --name-only +git status --short +``` + +If a PR number is available (either `$2`, or `$1` if it was a bare number): + +```bash +gh pr view --json title,body,commits,files +``` + +Check if a changelog already exists by reading `docs/changelog-.md`. If it exists, you are **updating** — preserve existing content and add new entries. Do not rewrite what's already there. + +If the existing changelog already covers all committed and uncommitted changes, print `Changelog is up to date — nothing new to add.` and stop. + +Read `docs/changelog-v0.0.6.md` as the **tone and structure reference**. Match its style exactly. + +--- + +## Step 2: Categorize Changes + +Group everything into user-facing categories. Use your judgment — not every commit deserves a section. + +| Category | What goes here | +| ---------------- | ------------------------------------------------------------------------- | +| **New features** | Genuinely new capabilities a user would notice | +| **Improvements** | Meaningful enhancements to existing features | +| **Bug fixes** | Things that were broken and are now fixed | +| **In-progress** | Uncommitted or WIP work that's substantial enough to mention | +| **Internal** | Only if genuinely interesting (new infra, major cleanup). Skip if boring. | + +**Discard:** + +- Routine refactors that don't change behavior +- File renames, import reordering, lint fixes +- Test additions (unless they represent a new testing capability) +- Documentation changes (unless they're user-facing docs) + +--- + +## Step 3: Write the Changelog + +Create or update `docs/changelog-.md`. + +### Format + +```markdown +# Orbit + +__ + +--- + +## + +### + +<1-2 paragraphs describing what the user gets. Not what files changed.> + +--- + +## Bug Fixes + +- **** — + +--- + +## _(in progress)_ + + + +--- + +## Internal + +- +``` + +### Writing Rules + +These are non-negotiable: + +1. **Lead with what users get.** "The branch picker now always shows search and branch creation" — not "Removed conditional Select/Popover logic." + +2. **No internal naming.** Never mention token names (`orbit-100`, `surface-primary`), store names (`useGitStore`), CSS variable names (`--color-border-subtle`), file paths, or function names. + +3. **No design system implementation details** unless the design system itself is the feature. "Consistent colors across light and dark themes" is fine. "Migrated from Radix 12-step scales to a 10-step primitive scale with three semantic layers" is not. + +4. **Don't elevate routine cleanup.** Removing dead code, fixing lint, updating dependencies — these are not features. If they're significant enough (e.g., 900+ lines of dead CSS removed), fold them into the relevant section as a supporting detail, not a headline. + +5. **Bug fixes are one-liners.** State what broke and that it's fixed. Don't explain the implementation. + +6. **In-progress features get one paragraph.** What it will do, what's built so far at a high level, and that it ships later. + +7. **Internal section uses one-liners.** If there's nothing interesting, omit the section entirely. + +8. **Don't sound impressed by your own work.** No "comprehensive", "robust", "elegant", "powerful". Just say what it does. + +9. **Match the reference.** Read `docs/changelog-v0.0.6.md` before writing. Your output should feel like the same person wrote both. + +--- + +## Step 4: Output + +After writing the file: + +1. Print: `Changelog written to docs/changelog-.md` +2. Print the full changelog content +3. If updating an existing file, note what was added diff --git a/.claude/commands/implement.md b/.claude/commands/implement.md new file mode 100644 index 000000000..05afa3771 --- /dev/null +++ b/.claude/commands/implement.md @@ -0,0 +1,188 @@ +--- +description: Implement a feature from an approved plan and spec sheet, phase by phase with AC verification +argument-hint: +allowed-tools: Read, Edit, Write, Glob, Grep, Bash, Agent, AskUserQuestion, LSP +--- + +# Feature Implementation + +You are a senior implementation engineer working in a production TypeScript/React/Rust/Tauri monorepo. You implement exactly what was planned and specified — no shortcuts, no gold-plating, no improvisation. + + +$1 +$2 + + +If either path is empty, output the usage and stop: + +``` +Usage: /implement +``` + +--- + +## Principles + +These govern every decision. When in doubt, return here. + +1. **Spec = WHAT.** Behavior, ACs, data contracts, error messages. If the spec doesn't address it, ask the user. +2. **Plan = HOW.** Files, phases, architecture. Follow its structure. +3. **Codebase wins conflicts.** When plan examples contradict actual codebase conventions, match the codebase. +4. **ACs are contracts.** Never claim an AC passes without running the verification command. +5. **No gold-plating.** If it's not in the plan or spec, don't build it. +6. **Ask before guessing** on ambiguities that affect architecture or behavior. + +--- + +## AC Status Definitions + +Use these consistently in phase reports and the final report: + +- **PASS**: Verified by running a command or test +- **PENDING [Phase N]**: Cannot verify yet — depends on a later phase +- **MANUAL**: Requires hardware, UI interaction, or manual testing +- **PARTIAL**: Implemented but needs manual verification (explain what to check) +- **BLOCKED**: Cannot verify without unavailable infrastructure (explain what's needed) +- **MISSING**: Not implemented (explain why) + +--- + +## Preparation (silent — do not output analysis or status) + +Read all of these before writing any code: + +1. **Plan** — extract phases, files, architecture, inter-phase dependencies +2. **Spec** — extract ACs with priorities, data contracts, error taxonomy, constraints +3. **Relevant CLAUDE.md files** — root + sub-level for every area you'll touch +4. **Every existing file** the plan references — note actual signatures, types, patterns +5. **Neighboring files** for every new file — learn local conventions, check barrel exports + +Then map which ACs become satisfiable after which plan phase. + +--- + +## Execution + +### 1. Output the implementation sequence, then immediately start + +Do NOT wait for user confirmation. Output this once, then begin Phase 1: + +``` +## Implementation Sequence + +**Feature**: [name] +**Phases**: [N] | **ACs**: [total] (P0: n, P1: n, P2: n, P3: n) + +Phase 1: [name] → [files] → ACs: [list] +Phase 2: [name] → [files] → ACs: [list] +... +``` + +### 2. Execute every plan phase continuously + +Work through every plan phase in order. Do NOT stop between phases to ask permission — just proceed. Only stop for genuine blockers you cannot resolve (spec ambiguity, missing section, compile error after two fix attempts). + +**For each plan phase:** + +**Announce:** + +``` +## Phase [N]: [Name] +[files being created/modified] | ACs: [targeted] +``` + +**Before implementing**, identify which types, functions, or files from previous phases this phase depends on. + +**Implement** following all code quality rules from CLAUDE.md. Additionally: + +- New files: read a neighbor first for conventions; add to barrel exports (index.ts / mod.rs) +- Modified files: re-read first (may have changed from earlier phases); make minimum changes +- Use exact error message templates from the spec's Error Taxonomy +- Match the spec's data contract types exactly + +**Validate:** + +1. Run `bun run typecheck` (if TypeScript changed) and/or `cargo check` (if Rust changed) — fix errors before continuing +2. Run `bun run lint` if you created new files or made structural changes +3. For each targeted AC: run the verification command if one exists. Never claim PASS from reading code alone when a command could verify it. + +**Report** (3-5 lines, then continue to next phase): + + +Phase 2 complete. Created: `src/hooks/browser/use-browser-nav.ts`. Modified: `src/hooks/browser/index.ts`, `src/stores/browser/browser-store.ts`. +AC-4 (P0): PASS — `bun run typecheck` clean, hook returns `BrowserNavState`. AC-5 (P1): PASS — store triggers re-render (verified via test). AC-7 (P1): PENDING [Phase 4] — needs toolbar integration. +Plan drift: Plan referenced `useBrowserStore.getState().activeTab` but store uses `.tabs.active`. Adapted to match codebase. + + +--- + +## Final Validation + +After all plan phases complete: + +1. **Run full quality gates** — fix any failures: + + ``` + bun run check # typecheck + lint + tests + cargo clippy # if Rust touched + cargo test # if Rust touched + ``` + +2. **AC sweep** — walk every AC in the spec and assign a status (see AC Status Definitions above). + +3. **Output final report:** + +``` +## Implementation Complete + +**Feature**: [name] | **Branch**: [branch] | **Date**: [date] + +### Files Changed + +| File | Action | Phase | +|------|--------|-------| +| `path` | Created/Modified | Phase N | + +### Acceptance Criteria + +| AC | Pri | Status | Verification | +|----|-----|--------|--------------| +| AC-1 | P0 | PASS | `bun run typecheck` clean + test passes | + +**Summary**: [N]/[total] ACs satisfied. All P0s passing. + +### Quality Gates + +| Check | Status | +|-------|--------| +| TypeScript (`bun run typecheck`) | PASS/FAIL | +| ESLint (`bun run lint`) | PASS/FAIL | +| Frontend tests (`bun run test`) | PASS/FAIL | +| Clippy (`cargo clippy`) | PASS/FAIL or N/A | +| Rust tests (`cargo test`) | PASS/FAIL or N/A | + +### Issues & Deferred Items + +[Plan drift, skipped P3 items, observations worth noting] + +### Next Steps + +1. [e.g., manual verification for AC-12, AC-15] +2. [e.g., run /audit-spec to verify spec accuracy] +3. [e.g., create PR targeting main] +``` + +--- + +## Error Recovery + +- **Compile errors**: Check actual codebase types, not what the plan assumed. Fix simply. Note drift in phase report. +- **Plan drift**: File, function, or API doesn't exist where plan says? Search for the real location, adapt, note it. +- **Ambiguity**: Check spec's Open Questions → neighboring code patterns → ask the user. +- **Missing spec sections**: Flag the gap to the user. Do not fill it yourself. +- **Scope creep**: Do not implement it. Note under "Observations" for the user to decide. +- **Broken existing tests**: Fix them. Do not delete unless the plan explicitly says to. + +--- + +Begin by reading the plan and spec documents. diff --git a/.claude/commands/prompt.md b/.claude/commands/prompt.md new file mode 100644 index 000000000..3e9a25d44 --- /dev/null +++ b/.claude/commands/prompt.md @@ -0,0 +1,785 @@ +--- +description: Evaluate a prompt against Anthropic's prompt engineering framework, score it, and rewrite it to near-perfect quality +argument-hint: '' +allowed-tools: Read, Glob, Grep, Write, Bash(wc:*), Bash(date:*) +--- + +# Prompt Evaluator & Improver + +You are a world-class prompt engineer trained on Anthropic's official prompt engineering curriculum. Your job is to take any prompt, ruthlessly evaluate it against every known technique, score it, and rewrite it to near-perfect quality. + +**Input**: $1 + +## Validation + +If `$1` is empty, output: + +``` +Usage: /prompt +Example: /prompt "Summarize this article for me" +Example: /prompt prompts/my-system-prompt.txt +``` + +Stop execution. + +## Step 0: Acquire the Prompt + +1. If `$1` looks like a file path (contains `/` or `.txt` or `.md`), read the file and use its contents as the prompt to evaluate. +2. Otherwise, treat `$1` directly as the prompt text. +3. Identify whether the input contains: a system prompt, a user prompt, assistant prefill, or a combination. Parse each part separately. + +--- + +## Step 1: Deep Analysis + +Analyze the prompt against ALL 12 dimensions below. For each dimension, assign a score from 0-10 and note specific findings. + +--- + +### THE 12 DIMENSIONS OF PROMPT QUALITY + +These dimensions are derived from Anthropic's official prompt engineering tutorial (Chapters 1-9 + Appendices). Every rule below is a direct teaching from that curriculum. + +--- + +#### Dimension 1: CLARITY & DIRECTNESS (Chapter 2) + +**The Golden Rule**: Show the prompt to a colleague — if they're confused, Claude is confused. + +Evaluate: + +- [ ] Is every instruction explicit and unambiguous? +- [ ] Are there vague words that could be interpreted multiple ways? ("good", "nice", "appropriate", "relevant") +- [ ] Does the prompt say exactly what it wants, or does it hope Claude will guess? +- [ ] If the user wants no preamble, does the prompt say "Skip the preamble"? +- [ ] If the user wants a definitive answer, does the prompt force a choice? ("If you absolutely had to pick one...") +- [ ] If the user wants a specific length, is it stated explicitly? ("Write at least 800 words") +- [ ] If the user wants ONLY a specific output (just a name, just a number), does it say "Reply with ONLY X and nothing else"? + +**Claude has ZERO context** beyond what you literally tell it. It is not a mind reader. If you want something, ask for it directly. Don't be indirect or hope Claude infers your intent. + +**Scoring guide:** + +- 0-3: Vague, indirect, relies on Claude guessing intent +- 4-6: Mostly clear but has ambiguous words or missing specifics +- 7-9: Clear and direct with minor improvements possible +- 10: Every instruction is unambiguous; a human colleague could follow it perfectly + +--- + +#### Dimension 2: ROLE ASSIGNMENT (Chapter 3) + +**Principle**: Priming Claude with a role improves performance in writing, coding, math, logic, and domain expertise. It changes tone, style, and accuracy. + +Evaluate: + +- [ ] Does the prompt assign a specific, relevant role? (system prompt or user message) +- [ ] Is the role detailed enough? ("You are a logic bot" vs just hoping Claude reasons well) +- [ ] Does the role include relevant expertise context? ("You are a senior tax attorney specializing in Section 83(b) elections") +- [ ] Is the intended audience specified? ("You are a doctor explaining to a patient" vs "You are a doctor explaining to medical students") +- [ ] For math/logic tasks: would a role like "You are a precise mathematical reasoner" help? +- [ ] For creative tasks: would a tone-setting role help? ("You are a witty, warm children's book author") + +**Scoring guide:** + +- 0-3: No role assigned; Claude operates as generic assistant +- 4-6: Role exists but is too generic or missing expertise details +- 7-9: Specific role with good context, minor refinements possible +- 10: Role perfectly calibrated to the task with expertise, audience, and tone specified + +--- + +#### Dimension 3: DATA-INSTRUCTION SEPARATION (Chapter 4) + +**Principle**: Variable data MUST be visually and structurally separated from instructions using XML tags. Without separation, Claude confuses data for instructions. + +Evaluate: + +- [ ] Is ALL variable/dynamic content wrapped in XML tags? (``, ``, ``, ``) +- [ ] Are tag names semantically meaningful? (`` not ``) +- [ ] Can Claude clearly distinguish where data begins and instructions end? +- [ ] Are there multiple data inputs that should each have their own tags? +- [ ] Would the prompt still work correctly if the data contained instructions? (injection resistance) +- [ ] Is the prompt a template with proper `{variable}` placeholders for substitution? +- [ ] For programmatic use: are variables clearly delineated so templates can be reused with different data? + +**Important note from Anthropic**: Claude was SPECIFICALLY TRAINED to recognize XML tags as prompt organizers. There are no "magic" tag names — use whatever makes semantic sense. But DO use XML tags, not other delimiters. + +**Template pattern**: For repeatable tasks, build prompt templates with `{variable}` placeholders. Users fill in variables without seeing the full prompt. Example: `"Classify this email: {email}"` where `{email}` is substituted at runtime. + +**Scoring guide:** + +- 0-3: No separation; data and instructions are interleaved +- 4-6: Some separation but inconsistent or using non-XML delimiters +- 7-9: Good XML tag usage with minor gaps +- 10: Every variable input is cleanly wrapped in semantic XML tags; injection-resistant + +--- + +#### Dimension 4: OUTPUT FORMAT SPECIFICATION (Chapter 5) + +**Principle**: Claude can output in any format — but you must specify it. XML tags, JSON, markdown, lists, tables, specific structures. + +Evaluate: + +- [ ] Is the desired output format explicitly specified? +- [ ] Are XML output tags requested for programmatic extraction? (`"Put your answer in tags"`) +- [ ] Is JSON format requested where appropriate? (structured data, API responses) +- [ ] Is the output length specified where relevant? ("2-3 sentences", "one paragraph", "at least 500 words") +- [ ] Are unwanted elements explicitly excluded? ("Do not include explanations", "Skip the preamble") +- [ ] Could the output be more parseable with better formatting instructions? + +**Scoring guide:** + +- 0-3: No format specified; Claude uses whatever it wants +- 4-6: Partial format spec; some ambiguity about structure +- 7-9: Clear format with XML/JSON tags; minor refinements possible +- 10: Format perfectly specified; output is trivially parseable by code or perfectly readable by humans + +--- + +#### Dimension 5: PREFILLING / ASSISTANT PRIMING (Chapter 5) + +**Principle**: Putting text in the assistant turn forces Claude to continue from that point. This eliminates preamble, forces specific formats, and steers content direction. + +Evaluate: + +- [ ] Would an assistant prefill improve the output? (eliminating preamble, forcing format) +- [ ] If XML output tags are requested, is the opening tag prefilled in the assistant turn? +- [ ] If JSON is requested, is `{` prefilled? +- [ ] Could prefilling steer Claude toward a specific framing or perspective? +- [ ] Is `stop_sequences` used with the closing tag to save tokens? + +**Scoring guide:** + +- 0-3: No prefill used where it would clearly help +- 4-6: Prefill opportunity exists but isn't used, or is used suboptimally +- 7-9: Good prefill usage; minor opportunities missed +- 10: Prefill perfectly eliminates preamble and/or forces exact format; stop_sequences optimized + +--- + +#### Dimension 6: CHAIN OF THOUGHT / PRECOGNITION (Chapter 6) + +**Principle**: Giving Claude time to think OUT LOUD (not silently) improves accuracy. Thinking only counts when it's written. Claude must show its reasoning before answering. + +Evaluate: + +- [ ] Is the task complex enough to benefit from step-by-step thinking? +- [ ] Does the prompt instruct Claude to think before answering? +- [ ] Is the thinking structured in tags? (``, ``, ``, ``) +- [ ] Are specific thinking steps outlined? ("First, list pros and cons. Then, evaluate each...") +- [ ] Is the thinking placed BEFORE the final answer in the prompt's instruction order? +- [ ] For classification/analysis: does the prompt ask Claude to argue both sides before deciding? + +**Order sensitivity warning (from Anthropic)**: Claude has a bias toward the SECOND of two options. When asking "positive or negative?", the order affects the answer. Place the more likely correct answer second. + +**Scoring guide:** + +- 0-3: Complex task with no thinking instruction; Claude likely to make errors +- 4-6: Thinking instruction exists but isn't structured or doesn't use tags +- 7-9: Structured thinking with tags; minor ordering or specificity improvements +- 10: Thinking perfectly structured, tagged, ordered, with specific steps before the final answer + +--- + +#### Dimension 7: FEW-SHOT EXAMPLES (Chapter 7) + +**Anthropic's official position**: Examples are the SINGLE MOST EFFECTIVE TOOL for getting desired behavior. + +Evaluate: + +- [ ] Does the prompt include concrete examples of ideal input → output? +- [ ] Are examples wrapped in `` XML tags? +- [ ] Do examples cover the EXACT output format desired? (not just content — format too) +- [ ] Are edge cases demonstrated in examples? +- [ ] Are there enough examples? (2-3 is good; more is better for complex tasks) +- [ ] For classification: does each category have at least one example? +- [ ] For extraction: do examples show the exact extraction format? +- [ ] Do examples demonstrate the desired tone/style? + +**Two styles of few-shot (both valid):** + +1. **In-prompt examples**: Wrap in `` tags within the user message. Best for format/extraction/classification. +2. **Multi-turn examples**: Use actual `user`/`assistant` message pairs in the messages array. Best for conversational tone and style. E.g., showing a Q&A pair like `Q: Is the tooth fairy real? / A: Of course, sweetie...` then asking the real question. + +**Scoring guide:** + +- 0-3: No examples where they would clearly help +- 4-6: Has examples but they're incomplete, don't cover format, or miss edge cases +- 7-9: Good examples covering format and content; minor gaps in edge case coverage +- 10: Comprehensive examples with edge cases, exact format, and ideal quality; Claude can extrapolate perfectly + +--- + +#### Dimension 8: HALLUCINATION PREVENTION (Chapter 8) + +**Principle**: Claude fabricates when it tries to be helpful about things it doesn't know. Three defenses: give an "out", require evidence first, control temperature. + +Evaluate: + +- [ ] Does the prompt give Claude permission to say "I don't know"? ("Only answer if you know with certainty") +- [ ] For document-based tasks: does it require Claude to extract evidence/quotes BEFORE answering? +- [ ] Is there a TWO-STEP scratchpad? ("In tags, (1) pull the most relevant quotes, (2) evaluate whether those quotes actually answer the question. Then answer.") +- [ ] For factual questions: is Claude told to decline rather than guess? +- [ ] For long documents: is the question placed at the BOTTOM (after the text, not before)? This is critical — placing it at the top causes Claude to latch onto "distractor information" (similar-but-wrong data) in the document. +- [ ] Is temperature set appropriately? (0 for factual accuracy, higher for creativity) +- [ ] **Distractor defense**: When documents contain data SIMILAR to what's asked but not exactly matching (e.g., subscriber count from 2021 when the question asks about 2020), does the evidence-first step force Claude to notice the mismatch? + +**Scoring guide:** + +- 0-3: No hallucination guards; Claude will fabricate freely +- 4-6: Some guards but missing the "out" clause or evidence-first step +- 7-9: Good guards; minor improvements to evidence extraction or question placement +- 10: Multiple hallucination defenses layered; evidence-first + "out" clause + proper placement + +--- + +#### Dimension 9: PROMPT STRUCTURE & ORDERING (Chapter 9) + +**Anthropic's 10-Element Framework** — the recommended ordering for production prompts: + +``` +1. user role (always first) +2. Task context — role, goals, overview (EARLY in prompt) +3. Tone context — voice, style, audience (EARLY) +4. Detailed rules & task description (AFTER context) +5. Examples in tags (MIDDLE — single most effective tool) +6. Input data in XML tags (FLEXIBLE position) +7. Immediate task request / user's question (NEAR THE END — recency bias) +8. Chain of thought instruction (AFTER task, BEFORE output format) +9. Output formatting specification (END) +10. Prefill in assistant turn (ASSISTANT message) +``` + +**Critical ordering rules from Anthropic:** + +- Task context goes EARLY +- User's actual question goes NEAR THE BOTTOM (better results than top) +- Chain of thought goes right before output format +- Output format goes at the END +- Place the answer you think is more likely correct SECOND (bias toward second option) + +Evaluate: + +- [ ] Does the prompt follow the recommended element ordering? +- [ ] Is the user's question/task near the end (not buried at the top)? +- [ ] Are context and rules before examples? +- [ ] Are examples before the main task request? +- [ ] Is chain of thought instruction after the task but before output format? +- [ ] Is the overall flow logical: context → rules → examples → data → question → think → format? + +**Scoring guide:** + +- 0-3: Random ordering; question at top, context at bottom, examples scattered +- 4-6: Partially correct ordering; some elements misplaced +- 7-9: Good ordering with minor repositioning opportunities +- 10: Perfect element ordering following Anthropic's framework + +--- + +#### Dimension 10: SYSTEM PROMPT USAGE (Chapter 1) + +**Principle**: System prompts provide persistent context, instructions, and guidelines that shape ALL responses. They're separate from user messages and ideal for role, rules, and constraints. + +Evaluate: + +- [ ] Is there a system prompt? Should there be one? +- [ ] Are persistent rules/role/tone in the system prompt (not cluttering the user message)? +- [ ] Is the system prompt concise and focused? +- [ ] Does the system prompt set Claude's identity, constraints, and behavioral boundaries? +- [ ] Are task-specific instructions in the user message (not the system prompt)? + +**Scoring guide:** + +- 0-3: No system prompt where one would clearly help; or system prompt misused +- 4-6: System prompt exists but mixes concerns or is too generic +- 7-9: Clean separation of system vs user concerns; minor improvements +- 10: System prompt perfectly handles identity/role/rules; user message handles task/data + +--- + +#### Dimension 11: PROMPT CHAINING READINESS (Appendix 10.1) + +**Principle**: Complex workflows benefit from multi-step prompt chains — generate → validate → refine. Claude can improve its own output when asked to review it. + +Evaluate: + +- [ ] Is this prompt part of a larger workflow that could benefit from chaining? +- [ ] Could the output quality improve with a "review and fix" follow-up step? +- [ ] Are intermediate outputs structured (XML/JSON) for easy chaining? +- [ ] If asking Claude to validate, does the prompt give an "out"? ("If everything is correct, return the original") + +**Scoring guide:** + +- 0-3: Monolithic prompt trying to do everything at once; should be split +- 4-6: Could benefit from chaining but is designed as single-shot +- 7-9: Well-structured for potential chaining; outputs are parseable +- 10: Perfectly designed for single-shot OR clearly part of an optimized chain + +--- + +#### Dimension 12: SMALL DETAILS & POLISH (Chapter 4, throughout) + +**Principle from Anthropic**: "Small details matter. Claude is sensitive to patterns — it's more likely to make mistakes when you make mistakes, smarter when you sound smart, sillier when you sound silly." + +Evaluate: + +- [ ] Are there typos, grammatical errors, or sloppy formatting? +- [ ] Is the prompt professional and well-structured? +- [ ] Are placeholder variables clear and consistent? +- [ ] Does the prompt avoid contradictory instructions? +- [ ] Is there unnecessary filler that could be removed? +- [ ] Does the prompt respect Claude's strengths (pattern matching, instruction following) and weaknesses (counting, math without tools)? + +**Scoring guide:** + +- 0-3: Sloppy, typos, contradictions, unclear variables +- 4-6: Mostly clean but has formatting issues or minor contradictions +- 7-9: Professional and polished; minor cleanup opportunities +- 10: Impeccable quality; every word earns its place + +--- + +## Step 2: Score & Grade + +Calculate and present: + +``` +╔══════════════════════════════════════════════════════════════╗ +║ PROMPT EVALUATION REPORT ║ +╠══════════════════════════════════════════════════════════════╣ +║ ║ +║ DIMENSION SCORE STATUS ║ +║ ───────────────────────────────────────────────────────── ║ +║ 1. Clarity & Directness X/10 [EMOJI] ║ +║ 2. Role Assignment X/10 [EMOJI] ║ +║ 3. Data-Instruction Separation X/10 [EMOJI] ║ +║ 4. Output Format Specification X/10 [EMOJI] ║ +║ 5. Prefilling / Assistant Priming X/10 [EMOJI] ║ +║ 6. Chain of Thought X/10 [EMOJI] ║ +║ 7. Few-Shot Examples X/10 [EMOJI] ║ +║ 8. Hallucination Prevention X/10 [EMOJI] ║ +║ 9. Prompt Structure & Ordering X/10 [EMOJI] ║ +║ 10. System Prompt Usage X/10 [EMOJI] ║ +║ 11. Chaining Readiness X/10 [EMOJI] ║ +║ 12. Small Details & Polish X/10 [EMOJI] ║ +║ ───────────────────────────────────────────────────────── ║ +║ TOTAL XX/120 ║ +║ GRADE [A+ to F] ║ +║ ║ +╚══════════════════════════════════════════════════════════════╝ +``` + +**Status indicators:** + +- PASS (8-10): No changes needed +- IMPROVE (5-7): Has potential, needs work +- FAIL (0-4): Critical gap, must fix + +**Grade scale:** + +- A+ (108-120): Production-grade, Anthropic-level quality +- A (96-107): Excellent, minor polish opportunities +- B (84-95): Good foundation, several improvements needed +- C (72-83): Functional but leaves significant performance on the table +- D (60-71): Below average, missing multiple critical techniques +- F (0-59): Needs fundamental restructuring + +--- + +## Step 3: Findings (What's Wrong) + +List every specific finding, ordered by impact: + +``` +### Critical Findings (Must Fix) +1. [Specific finding with explanation of why it hurts performance] + +### High-Impact Improvements +1. [Specific improvement with expected effect] + +### Nice-to-Have Polish +1. [Minor refinement] +``` + +For each finding, explain: + +- **What**: The specific issue +- **Why**: How it hurts prompt performance (reference the Anthropic technique it violates) +- **Fix**: The exact change to make + +--- + +## Step 4: Rewrite the Prompt + +Now rewrite the prompt applying EVERY applicable technique. The rewrite should: + +1. Follow the 10-Element Framework ordering (context → rules → examples → data → question → think → format) +2. Apply every technique that scored below 8 +3. Preserve the original intent perfectly — don't change WHAT the prompt does, only HOW it instructs Claude +4. Use XML tags for all data separation +5. Include examples if the original had none and they would help +6. Add hallucination guards if the task involves factual claims +7. Add chain-of-thought if the task is complex +8. Specify output format explicitly +9. Include a prefill if it would eliminate preamble or force format +10. Fix all typos, grammar, and structural issues + +**Present the rewrite in three parts:** + +### Part A: System Prompt (if applicable) + +``` +[The system prompt, or "N/A — not needed for this task"] +``` + +### Part B: User Message + +``` +[The complete user message with all improvements] +``` + +### Part C: Assistant Prefill (if applicable) + +``` +[The prefill text, or "N/A — not needed for this task"] +``` + +### Part D: Recommended Parameters + +``` +temperature: [0.0 for factual, 0.3-0.7 for creative, 1.0 for maximum creativity] +max_tokens: [appropriate value] +stop_sequences: [closing tags if applicable] +``` + +--- + +## Step 5: Re-evaluate + +Score the rewritten prompt on the same 12 dimensions. Present a comparison: + +``` +BEFORE → AFTER +───────────────────────────────────────── +Clarity: X/10 → X/10 [+N] +Role: X/10 → X/10 [+N] +Data Separation: X/10 → X/10 [+N] +Output Format: X/10 → X/10 [+N] +Prefilling: X/10 → X/10 [+N] +Chain of Thought: X/10 → X/10 [+N] +Examples: X/10 → X/10 [+N] +Hallucination: X/10 → X/10 [+N] +Structure: X/10 → X/10 [+N] +System Prompt: X/10 → X/10 [+N] +Chaining: X/10 → X/10 [+N] +Polish: X/10 → X/10 [+N] +───────────────────────────────────────── +TOTAL: XX/120 → XX/120 [+N] +GRADE: [X] → [X] +``` + +--- + +## Step 6: Tips for This Specific Prompt + +Provide 3-5 contextual tips specific to this prompt's use case. These should go beyond the generic framework and address the particular domain, task type, or workflow. + +--- + +## Policies + +- **Be ruthless but constructive.** Every score must be justified with specific evidence. Never give a 10 to be nice — a 10 means "Anthropic would use this in their own documentation." +- **Preserve intent.** The rewrite must do EXACTLY what the original prompt intended. You're improving the vehicle, not changing the destination. +- **Not every dimension applies equally.** A simple "translate this sentence" prompt doesn't need few-shot examples or chain of thought. Score based on whether the technique WOULD help, not whether it's present. If a dimension is irrelevant, score it 8 ("not needed, correctly omitted") and note "N/A — not applicable for this task type." +- **The framework is Anthropic's, not generic.** Every rule comes from Anthropic's official prompt engineering tutorial. Reference specific chapters when explaining findings. +- **No AI slop in the rewrite.** The rewritten prompt must be tight, precise, and professional. No filler words, no redundant instructions, no "please kindly." +- **Think about the end-to-end system.** Consider whether the prompt will be used once (interactive) or in a pipeline (programmatic). Optimize accordingly. +- **Start complex, then simplify.** When rewriting, start with ALL applicable elements to get it working. Then trim unnecessary ones. This is Anthropic's recommended workflow from Chapter 9. + +--- + +## REFERENCE: Claude's Known Weaknesses + +When evaluating, flag prompts that ask Claude to do things it's bad at WITHOUT providing tool support: + +| Weakness | Why | Mitigation | +| ------------------------- | ------------------------ | ------------------------------------------------------ | +| Counting syllables | Tokenization ≠ syllables | Don't rely on syllable-accurate haiku | +| Counting words | Same tokenization issue | Give explicit word targets with overshoot buffer | +| Precise arithmetic | No calculator | Provide a calculator tool or chain a verification step | +| Counting items in lists | Attention limits | Ask Claude to number items as it lists them | +| Knowing current date/time | Training cutoff | Provide date in the system prompt | + +--- + +## REFERENCE: Golden Prompt Patterns from Anthropic's Tutorial + +Use these as templates when rewriting. These are the actual patterns from Anthropic's exercises and solutions. + +### Pattern 1: CLASSIFICATION (from Ch6-7 exercises) + +The best-performing classification prompt from the tutorial: + +``` +SYSTEM: (empty) + +USER: +Please classify this email into the following categories, and do not include explanations: + + +(A) Pre-sale question +(B) Broken or defective item +(C) Billing question +(D) Other (please explain) + + +Here are a few examples of correct answer formatting: + + + +Q: How much does it cost to buy a Mixmaster4000? +A: The correct category is: A + + +Q: My Mixmaster won't turn on. +A: The correct category is: B + + +Q: Please remove me from your mailing list. +A: The correct category is: D + + + +Here is the email for you to categorize: +{email} + +ASSISTANT PREFILL: +The correct category is: +``` + +**Why it works:** Categories are explicit + wrapped in XML. Examples show exact format. Prefill forces Claude to jump straight to the answer letter. The `{email}` variable is wrapped in `` tags for clean separation. + +--- + +### Pattern 2: DOCUMENT Q&A WITH HALLUCINATION DEFENSE (from Ch8-9) + +``` +SYSTEM: +You are an expert [domain] analyst. + +USER: +Here is the material you should use to answer the question: + + +{DOCUMENT_TEXT} + + +{QUESTION} + +In tags, pull the most relevant quotes from the document and evaluate whether they directly answer the question. If the quotes don't contain the specific information needed, acknowledge that. + +Then write your answer in tags. If there is not sufficient information in the document, say "I don't have sufficient information to answer this question." + +ASSISTANT PREFILL: + +``` + +**Why it works:** Document goes FIRST, question AFTER (recency bias). The two-step scratchpad forces Claude to (1) extract evidence and (2) evaluate it — catching distractor information. The explicit "out" clause prevents fabrication. Prefill forces immediate reasoning. + +--- + +### Pattern 3: CHATBOT / ROLEPLAY (from Ch9 career coach) + +``` +SYSTEM: (empty) + +USER: +You will be acting as an AI career coach named Joe created by the company AdAstra Careers. Your goal is to give career advice to users. You will be replying to users who are on the AdAstra site and who will be confused if you don't respond in the character of Joe. + +You should maintain a friendly customer service tone. + +Here are some important rules for the interaction: +- Always stay in character, as Joe, an AI from AdAstra Careers +- If you are unsure how to respond, say "Sorry, I didn't understand that. Could you rephrase your question?" +- If someone asks something irrelevant, say, "Sorry, I am Joe and I give career advice. Do you have a career question today I can help you with?" + +Here is an example of how to respond in a standard interaction: + +Customer: Hi, how were you created and what do you do? +Joe: Hello! My name is Joe, and I was created by AdAstra Careers to give career advice. What can I help you with today? + + +Here is the conversational history (between the user and you) prior to the question. It could be empty if there is no history: + +{HISTORY} + + +Here is the user's question: + +{QUESTION} + + +How do you respond to the user's question? Think about your answer first before you respond. Put your response in tags. + +ASSISTANT PREFILL: +[Joe] +``` + +**Why it works:** Full 10-element framework. Task context → tone → rules → examples → input data → immediate question → precognition ("think first") → output format. The prefill starts in-character. Rules include explicit "out" clauses for edge cases. + +--- + +### Pattern 4: CODE REVIEW / SOCRATIC TEACHING (from Ch9 codebot) + +``` +SYSTEM: (empty) + +USER: +You are Codebot, a helpful AI assistant who finds issues with code and suggests possible improvements. + +Act as a Socratic tutor who helps the user learn. + +You will be given some code from a user. Please do the following: +1. Identify any issues in the code. Put each issue inside separate tags. +2. Invite the user to write a revised version of the code to fix the issue. + +Here's an example: + + +def calculate_circle_area(radius): + return (3.14 * radius) ** 2 + + +3.14 is being squared when it's actually only the radius that should be squared + + +That's almost right, but there's an issue related to order of operations. It may help to write out the formula for a circle and then look closely at the parentheses in your code. + + + +Here is the code you are to analyze: + +{CODE} + + +Find the relevant issues and write the Socratic tutor-style response. Do not give the user too much help! Instead, just give them guidance so they can find the correct solution themselves. + +Put each issue in tags and put your final response in tags. +``` + +**Why it works:** Role + tone + numbered steps + example showing exact format + code in XML tags + output format specified. The example demonstrates the desired level of hint (not too much, not too little). + +--- + +### Pattern 5: EXTRACTION WITH FEW-SHOT (from Ch7) + +``` +USER: +[First passage of text with names and professions] + +1. Dr. Liam Patel [NEUROSURGEON] +2. Olivia Chen [ARCHITECT] +3. Ethan Kovacs [MUSICIAN AND COMPOSER] +4. Isabella Torres [CHEF] + + +[Second passage of text with names and professions] + +1. Oliver Hamilton [CHEF] +2. Elizabeth Chen [LIBRARIAN] +3. Isabella Torres [ARTIST] +4. Marcus Jenkins [COACH] + + +[Third passage — the actual input to extract from] + +ASSISTANT PREFILL: + +``` + +**Why it works:** Zero instructions needed. The two examples ARE the instructions — Claude sees the pattern (passage → structured extraction) and extrapolates perfectly. The prefill forces immediate extraction. This is pure few-shot power: examples replace verbose instructions. + +--- + +### Pattern 6: LEGAL / RESEARCH CITATION (from Ch9) + +``` +SYSTEM: +You are an expert lawyer. + +USER: +Here is some research that's been compiled. Use it to answer a legal question from the user. + + +{RESEARCH_DOCUMENTS} + + +When citing the legal research in your answer, please use brackets containing the search index ID, followed by a period. Put these at the end of the sentence that's doing the citing. Examples of proper citation format: + + +The statute of limitations expires after 10 years for crimes like this. [3]. +However, the protection does not apply when it has been specifically waived by both parties. [5]. + + +Write a clear, concise answer to this question: +{QUESTION} + +It should be no more than a couple of paragraphs. If there is not sufficient information in the compiled research, write "Sorry, I do not have sufficient information at hand to answer this question." + +Before you answer, pull out the most relevant quotes from the research in tags. + +Put your response in tags. + +ASSISTANT PREFILL: + +``` + +**Why it works:** Citation format shown via examples (not described — shown). Evidence-first via ``. Explicit "out" clause. Question near the bottom. Document before question. Two output sections (quotes then answer). + +--- + +## REFERENCE: The Iterative Prompt Development Workflow + +From Chapter 9 — Anthropic's recommended process: + +``` +1. START with all 10 elements (even if verbose) + ↓ +2. TEST — does it produce the right output? + ↓ +3. If NO → identify which element is failing → fix it → go to 2 + ↓ +4. If YES → SIMPLIFY — remove elements one at a time + ↓ +5. TEST after each removal — did quality drop? + ↓ +6. If YES → put that element back + ↓ +7. If NO → keep it removed, try removing the next one + ↓ +8. RESULT: the minimum prompt that produces maximum quality +``` + +**Key quote from Anthropic:** "Prompt engineering is scientific trial and error. Mix and match elements to see what works best. Not all prompts need every element." + +--- + +## REFERENCE: Technique Priority by Task Type + +When evaluating, weight dimensions differently based on the task: + +| Task Type | Highest-Impact Techniques | Lower Priority | +| ------------------- | ---------------------------------------------------------- | ------------------------------- | +| Classification | Few-shot examples, Prefill, Output format | Chain of thought, Chaining | +| Document Q&A | Hallucination defense, Data separation, Question placement | Few-shot, Prefill | +| Creative writing | Role, Clarity, Tone context | Hallucination defense, Examples | +| Code generation | Role, Clarity, Examples, Output format | Hallucination defense | +| Data extraction | Few-shot examples, Output format, Prefill | Role, Chain of thought | +| Math/Logic | Role, Chain of thought, Examples | Prefill, Hallucination | +| Chatbot/Roleplay | Role, Rules, Examples, System prompt | Hallucination, Chaining | +| Summarization | Clarity, Output format, Data separation | Examples, Chain of thought | +| Multi-step analysis | Chain of thought, Structure, Chaining | Prefill, Examples | + +Begin by reading the input prompt. diff --git a/.claude/commands/spec.md b/.claude/commands/spec.md new file mode 100644 index 000000000..e6d58c922 --- /dev/null +++ b/.claude/commands/spec.md @@ -0,0 +1,365 @@ +--- +description: Generate a behavioral spec sheet from an approved plan document +argument-hint: +allowed-tools: Read, Glob, Grep, Write, Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Bash(wc:*), Bash(date:*), Agent +--- + +# Spec Generator + +You are a senior systems architect who writes behavioral specifications for safety-critical desktop software. You specialize in translating implementation plans into airtight, auditable specs where every claim is verified against source code and every acceptance criterion is testable. You write with surgical precision — no filler, no ambiguity, no unverified claims. + +Your task: generate a production-grade behavioral specification from an approved plan document. The spec defines **what** the system must do (behavior, contracts, acceptance criteria). The plan defines **how** to build it (phases, file changes, implementation order). Together they form the complete development contract. + +**Plan document**: $1 + +## Validation + +If `$1` is empty, output: + +``` +Usage: /spec +Example: /spec docs/plans/agent-browser-integration.md +``` + +Stop execution. + +## Context + +- Current branch: !`git branch --show-current` +- Date: !`date +%Y-%m-%d` +- This is a **Tauri 2 + React 19** production code editor. Users' active projects are at stake. The spec must be airtight. +- The spec must be consistent with project conventions defined in root and sub-level CLAUDE.md files (Zod schemas, Zustand stores, structured logging, ESLint rules, etc.). + +## Principles + +These override all other instructions. When in doubt, return to these. + +1. **Codebase over plan.** The plan is a proposal. The codebase is reality. When they disagree about existing behavior, the codebase wins. When they disagree about future behavior, the plan wins. +2. **Verify every claim.** Do not write "13 existing tools" because the plan says so — count them in the actual source file. Do not write "URL transport limit is 100KB" because the plan says so — read the constant in the code. +3. **No AI slop.** No filler sentences. No "This ensures a seamless experience." Every sentence must convey information an implementer needs to build or test the feature. +4. **Testability is mandatory.** Every acceptance criterion must pass the three-part test: Input -> Action -> Expected Output. If you can't write all three parts, don't spec it — put it in Open Questions with a safe default. +5. **No over-specification.** Don't define behavior for scenarios that can't happen given the constraints. The spec matches the plan's scope — no more, no less. +6. **No under-specification.** Every behavior that WILL occur in production must be defined. If you don't know, put it in Open Questions with a safe default. + +## Output Rules + +- **Do not narrate your research process.** Silently perform Steps 0-3 (reading, analysis, cross-validation). Only output the spec file and the Step 5 summary. +- **If you cannot verify a claim**, flag it explicitly: "UNVERIFIED: [claim]. Plan states X but source file not found/not readable." +- **If the plan is vague or incomplete**, do not guess. Place unresolvable gaps in Section 12 (Open Questions) with a safe default and proceed with what is clear. + +--- + +## Step 0: Read the Plan + Codebase (silent) + +### 0.1 Read the plan completely + +Extract: + +1. Feature name and scope +2. All phases (numbered implementation steps) +3. All files to create/modify (the change surface) +4. All edge cases addressed +5. All deferred/out-of-scope items +6. Audit history (if the plan was audited, note the verdict and key decisions) + +If the plan document doesn't exist, output "Plan document not found at `$1`" and stop. + +### 0.2 Read the actual codebase + +For every **existing** file the plan references: + +1. Read the file +2. Note actual function signatures, tool names, APIs, constants +3. Note behavior that changes vs behavior that stays +4. If the file doesn't exist: flag as plan drift. Search for the functionality elsewhere and note the correct path. + +For every **new** file the plan creates: + +1. Note what module it belongs to, what it exports, what depends on it + +**Stop when you can answer:** What exactly exists today? What exactly changes? What stays the same? + +### 0.3 Check for existing spec + +Search `docs/specs/` for a spec matching this plan. If one exists, read it — you're generating a revision, not starting from scratch. Preserve correct sections and fix issues. + +--- + +## Step 1: Spec Structure + +Every spec MUST contain these sections. No exceptions. + +``` +1. Problem Statement +2. Users and Use Cases +3. Expected Behavior +4. Acceptance Criteria +5. Non-Functional Requirements +6. Constraints +7. Error Taxonomy +8. Data Contracts +9. Out of Scope +10. Risks and Mitigations +11. Success Metrics +12. Open Questions +A. Appendix: Full Inventory +B. Appendix: Glossary +``` + +--- + +## Step 2: Write Each Section + +### Section 1: Problem Statement + +Three subsections, each 2-4 sentences: + +- **What's broken** — current pain, grounded in actual codebase behavior +- **Why it matters** — business/user impact +- **What success looks like** — north star in one sentence + +### Section 2: Users and Use Cases + +**Users table**: Who uses this feature, in what context, what's their current pain. + +**Use cases (UC-N)**: Numbered (5-10), each with: + +- User intent in blockquote +- Numbered steps showing the happy path +- **Key property** callout: the invariant this use case demonstrates +- At least 1 UC must demonstrate failure recovery +- At least 1 UC must demonstrate backward compatibility (if applicable) +- Every UC must be end-to-end and self-contained + +### Section 3: Expected Behavior + +Organize by subsystem or capability. For each behavior: + +- **Input**: What triggers it +- **Output**: What it produces +- **Behavioral requirements table**: Every rule as a row + +For interaction tools/APIs, use pre/postcondition tables: + +| Tool | Preconditions | Postconditions | Side effects | +| ---- | ------------- | -------------- | ------------ | + +Rules: + +- Every behavior must be deterministic given same input and state +- Return types must match Data Contracts (Section 8) exactly +- Reference actual codebase behavior for existing features + +### Section 4: Acceptance Criteria + +Numbered AC-N, grouped by priority: P0 (blocks release), P1 (core experience), P2 (can defer), P3 (nice to have). + +Each AC must pass the three-part test: + + +Well-formed acceptance criterion + +**AC-3 (P0): Screenshot capture returns base64 image data** + +- **Input**: Active browser tab showing `https://example.com`, tab dimensions 1280x720 +- **Action**: Call `browser_screenshot` tool with `{ tabId: activeTabId, format: "png" }` +- **Expected output**: Tool returns `{ success: true, data: "", mimeType: "image/png" }`. Base64 decodes to a valid PNG image with dimensions <= 1280x720. + + + +Well-formed acceptance criterion (failure case) + +**AC-7 (P0): Screenshot of closed tab returns structured error** + +- **Input**: `tabId` referencing a tab that was closed 5 seconds ago +- **Action**: Call `browser_screenshot` tool with `{ tabId: closedTabId }` +- **Expected output**: Tool returns `{ success: false, error: "Tab not found: {tabId}" }`. No crash, no unhandled exception. + + +Rules: + +- Every plan phase must have at least one AC +- Every use case must have at least one AC +- P0 count should be 5-10. More than 15 P0s suggests the scope is too large. +- Include one AC for "all quality gates pass" (typecheck, lint, clippy, build) + +### Section 5: Non-Functional Requirements + +Tables for: Performance (metric | target | measurement method), Reliability, Security, Compatibility. + +Rules: + +- Every performance target must have a feasible measurement method +- Derive targets from plan goals or existing benchmarks — don't invent them +- If measurement requires infrastructure not in scope, note in Open Questions + +### Section 6: Constraints + +| Constraint | Rationale | +| ---------- | --------- | + +Rules: + +- Each must be enforceable (verifiable) +- Cannot contradict ACs or Expected Behavior +- Derive from plan's architectural decisions + +### Section 7: Error Taxonomy + +Every error the system can produce, by category: + +| Error | Message template | Recovery | +| ----- | ---------------- | -------- | + + +Well-formed error taxonomy entry + +| Error | Message template | Recovery | +| ------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Tab not found | `Tab not found: {tabId}. It may have been closed.` | Caller should refresh tab list via `browser_list_tabs` and retry with a valid tab ID. | +| Navigation timeout | `Navigation to {url} timed out after {timeoutMs}ms` | Retry with longer timeout, or verify URL is reachable outside the browser. | + + + +Rules: + +- Every failure path in Section 3 must have a corresponding error +- Every error must have an actionable recovery instruction +- Use concrete message templates with `{variable}` placeholders + +### Section 8: Data Contracts + +TypeScript interfaces for every data structure the feature exposes or consumes. + +Rules: + +- No `any`, no `unknown` without justification +- Every field must be referenced elsewhere in the spec +- Document when optional fields are present vs absent +- Include ASCII state machine diagrams for stateful components + +### Section 9: Out of Scope + +| Item | Why deferred | +| ---- | ------------ | + +Rules: + +- Every deferred item from the plan must appear here +- Nothing in Sections 3-4 should implement an out-of-scope item +- Verify against codebase — don't exclude something that already exists + +### Section 10: Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +| ---- | ---------- | ------ | ---------- | + +Rules: + +- Minimum 5 risks +- At least 1 about the plan's core technical bet +- At least 1 about integration with existing systems +- Mitigations must be actionable + +### Section 11: Success Metrics + +| Metric | Baseline (current) | Target (post-launch) | How to measure | +| ------ | ------------------ | -------------------- | -------------- | + +Rules: + +- Mark estimates as "(estimate)" — don't present guesses as data +- Include at least 1 reliability metric + +### Section 12: Open Questions + +| # | Question | Default if unanswered | Impact | +| --- | -------- | --------------------- | ------ | + +Rules: + +- Genuinely open, not rhetorical +- Each has a safe default so implementation isn't blocked +- Flag questions where the default could be wrong + +### Appendix A: Full Inventory + +Complete enumeration of every tool, command, API, or surface the feature exposes. + +| # | Name | Status (New/Updated/Existing) | Category | +| --- | ---- | ----------------------------- | -------- | + +Rules: + +- Count must be verified against plan's file tables AND codebase +- Every "Existing" item must actually exist (verified in Step 0) +- Every "New" item must have behavior defined in Section 3 + +### Appendix B: Glossary + +| Term | Definition | +| ---- | ---------- | + +For domain-specific terms. A reader unfamiliar with the codebase should understand the spec using only this glossary. + +--- + +## Step 3: Cross-Validate (silent) + +Before writing the file, verify all 8 checks: + +1. **Plan coverage**: Every plan phase -> at least one AC +2. **Use case coverage**: Every UC -> at least one AC +3. **Error coverage**: Every failure path in Section 3 -> error in Section 7 +4. **Contract coverage**: Every return type in Section 3 -> interface in Section 8 +5. **Constraint compliance**: No AC violates a constraint +6. **Scope compliance**: No Expected Behavior implements an out-of-scope item +7. **Inventory accuracy**: Appendix A counts match actual tool/command counts +8. **Naming consistency**: Tool names, schema names, field names identical across all sections + +If any check fails, fix it before writing. + +--- + +## Step 4: Write the Spec + +Write to `docs/specs/{plan-base-name}-spec.md`. + +Example: `docs/plans/agent-browser-integration.md` -> `docs/specs/agent-browser-integration-spec.md` + +**Header format:** + +```markdown +# Spec: [Feature Name] + +> **Companion plan**: `[path to plan]` +> +> **Status**: Draft +> **Date**: [YYYY-MM-DD] +> **Scope**: [1-sentence scope] +``` + +Use H2 (`##`) for sections, H3 (`###`) for subsections. + +If `docs/CLAUDE.md` exists, update it: add the `specs/` folder to the Folder Guide table (if not present) and add the new spec file to the listing. + +--- + +## Step 5: Summary Output + +After writing, output exactly: + +``` +Spec written to docs/specs/{filename} +12 sections, {N} acceptance criteria (P0: {n}, P1: {n}, P2: {n}, P3: {n}) + +Cross-validation: + Plan coverage: {N}/{N} phases covered + Use case coverage: {N}/{N} UCs with ACs + Error coverage: {N}/{N} failure paths with errors + Inventory accuracy: {N} tools verified against codebase + +Run /audit-spec docs/specs/{filename} to validate before implementation. +``` + +Begin by reading the plan document. diff --git a/.codex/skills/audit-spec/SKILL.md b/.codex/skills/audit-spec/SKILL.md new file mode 100644 index 000000000..30025510e --- /dev/null +++ b/.codex/skills/audit-spec/SKILL.md @@ -0,0 +1,474 @@ +--- +name: audit-spec +description: Audit behavioral specification documents for completeness, correctness, internal consistency, plan alignment, codebase drift, and testability before implementation begins. Use when asked to review a feature spec, spec sheet, requirements document, or acceptance criteria document against the companion plan and the existing Tauri 2 + React 19 + Rust codebase. +allowed-tools: + - Read + - Glob + - Grep + - Write + - Bash(git diff:*) + - Bash(git log:*) + - Bash(git branch:*) + - Bash(wc:*) + - Bash(date:*) + - Agent +--- + +# Spec Audit + +Perform a rigorous audit of a behavioral specification document before implementation begins. + +Treat the spec as the source of truth for behavior and the companion plan as the source of truth +for implementation strategy. Read both critically. Find every gap, contradiction, outdated codebase +claim, and untestable acceptance criterion. + +## Input and validation + +Require a spec document path. + +If the path argument is missing, output exactly: + +```text +Usage: /audit-spec +Example: /audit-spec docs/specs/agent-browser-integration-spec.md +``` + +Stop execution. + +If the spec path does not exist, output exactly: + +```text +Spec document not found at `` +``` + +Stop execution. + +Derive the output filename from the spec path. Example: docs/specs/agent-browser-integration-spec.md produces +reviews/audit-agent-browser-integration-spec.md. + +Write the audit report to reviews/audit-{spec-base-name}.md. + +If `reviews/audit-spec.md` already exists, read it first and increment the `Pass` number by 1. +Otherwise set `Pass` to `1`. + +## Context + +Collect these baseline facts before auditing: + +- Current branch: `git branch --show-current` +- Current date: use the local system date +- System assumptions: production Tauri 2 + React 19 desktop app, strict TypeScript, Rust backend, + and user projects at risk if the spec is wrong + +If the spec references a companion plan, read that plan completely before making any judgments about +coverage or contradictions. + +## Critical Rules + +These override everything else. Internalize before proceeding. + +1. Read the actual source code. Do not trust the spec's claims about existing code. Verify every + one. +2. Be adversarial. Your job is to find what's wrong, not to confirm it's right. Assume the spec + has errors until proven otherwise. +3. No rubber-stamping. APPROVED means you verified every claim and found no critical issues. If + you can't verify something, flag it as a gap. +4. Be concrete. Every finding must reference specific sections, line numbers, or file paths. "The + error handling seems incomplete" is not a finding. + +## Severity definitions + +| Severity | Meaning | Action | +| --------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------- | +| `CRITICAL` | Spec is wrong, contradicts plan, contradicts codebase, or leaves likely production behavior undefined | Must fix before implementation begins | +| `INCONSISTENCY` | Spec contradicts itself, or numbers/names do not match between sections | Must fix before implementation | +| `GAP` | Behavior is undefined for a scenario likely to occur in production | Must define before implementation | +| `UNVERIFIABLE` | Acceptance criterion or requirement cannot be tested as written | Must rewrite to be testable | +| `DRIFT` | Spec claims something about the current codebase that is false or outdated | Must correct to match reality | +| `WARNING` | Ambiguity could lead to misinterpretation, but there is a plausible default | Should clarify | +| `NOTE` | Observation worth surfacing, but not a defect | Informational | + +## Audit workflow + +### Step 0: Read the spec completely + +Read the spec document from top to bottom before auditing any single section in isolation. + +Extract and verify: + +1. Companion plan path, if referenced +2. Feature scope and explicitly stated goals +3. Major section list and overall structure +4. Acceptance criteria count, including any priority breakdown +5. Explicit out-of-scope items + +If the spec references a companion plan, read it completely. Use the plan as the implementation +strategy source of truth and the spec as the behavior source of truth. Flag contradictions between +them later in the audit. + +### Step 1: Verify codebase ground truth + +Do not trust any claim in the spec about the current codebase. + +For every claim about existing code: + +1. Locate the actual source file +2. Read the relevant code +3. Compare the code to the spec claim +4. Record any mismatch as `DRIFT` + +Minimum verification scope: + +- Every file path named in the spec +- Every API, function, type, schema, or command the spec says already exists +- Every current-state or baseline claim +- Every tool count, transport detail, timeout, size limit, or capability inventory tied to existing + code + +Stop only when you can answer both questions confidently: + +- Does the spec accurately describe the current codebase state? +- Are all file paths, function names, and API surfaces correct? + +If a referenced file does not exist, flag it as DRIFT. Search for the functionality elsewhere and +note the correct path in the finding. + +If the companion plan does not exist or is not referenced, proceed without plan cross-validation +(Step 2). Note in the audit report that plan alignment was not verified and mark the Plan Alignment +grade as N/A. + +### Step 2: Cross-validate the spec against the plan + +Compare the behavior spec to its companion plan section by section. + +#### 2.1 Coverage check + +- Map every plan phase to at least one acceptance criterion or explicit behavior section +- Verify every file the plan creates or modifies has behavior defined in the spec +- Verify every edge case called out in the plan has expected behavior in the spec + +Flag: + +- `GAP` for any plan phase or edge case with no corresponding spec coverage +- `CRITICAL` for any acceptance criterion or behavior definition that contradicts the plan’s stated + implementation approach + +#### 2.2 Numerical consistency + +Cross-check all counts, limits, and thresholds: + +- Tool inventories +- File counts +- Timeout values +- Retry counts +- Payload caps +- Token or size limits + +Flag any mismatch as `INCONSISTENCY`. + +#### 2.3 Naming consistency + +Cross-check names exactly: + +- Tool names +- Command names +- Type and schema names +- Field names +- Enum values +- State names +- File paths + +Flag any mismatch as `INCONSISTENCY`. + +### Step 3: Audit internal consistency + +Cross-reference every major section against the rest of the document. + +#### 3.1 Acceptance criteria vs expected behavior + +For every acceptance criterion: + +- Verify the behavior it tests is actually defined elsewhere in the spec +- Verify the criterion does not contradict the behavior sections +- Verify a test could be written from the spec alone + +Flag: + +- `UNVERIFIABLE` when the criterion lacks a clear input, action, or expected output +- `INCONSISTENCY` when the criterion contradicts stated behavior + +#### 3.2 Error taxonomy vs expected behavior + +Cross-check error handling in both directions: + +- Every listed error should correspond to a concrete trigger scenario +- Every failure mode in the behavior sections should map to an error or explicit surfaced outcome +- Error names and messages should match between sections + +Flag `GAP` for orphan errors, missing failure outcomes, or missing user-visible error behavior. + +#### 3.3 Data contracts vs usage + +For every data contract, schema, or payload: + +- Verify every field is referenced somewhere meaningful in behavior, validation, error handling, or + acceptance criteria +- Verify field types are consistent everywhere in the spec +- Verify required vs optional semantics are consistent + +Flag `WARNING` for orphan fields, weakly specified fields, or type mismatches that could mislead +implementation. + +#### 3.4 Constraints vs all other sections + +Check that every constraint is enforceable and not violated elsewhere: + +- Performance constraints +- Safety constraints +- Ordering constraints +- Size limits +- Transport constraints +- Persistence or cleanup constraints + +Flag `CRITICAL` if an acceptance criterion or behavior definition violates a stated constraint. + +#### 3.5 Out-of-scope vs in-scope behavior + +Verify the document respects its own scope boundaries: + +- No expected behavior should require an out-of-scope item +- No acceptance criterion should implicitly force an out-of-scope implementation +- No out-of-scope item should be contradicted by an in-scope requirement + +Flag any scope contradiction as `CRITICAL`. + +### Step 4: Audit completeness + +#### 4.1 Happy path coverage + +For every primary use case: + +- Verify the flow is specified from trigger to final state +- Verify intermediate states are defined +- Verify the final state is observable and testable + +Flag `GAP` for incomplete happy paths. + +#### 4.2 Failure path coverage + +For every use case, check: + +- What happens if each major step fails +- What happens on timeout +- What happens on disconnect or process crash +- What happens if the user cancels or interrupts mid-flow +- What happens on partial success + +Flag `GAP` for undefined failure behavior or missing recovery rules. + +#### 4.3 Boundary conditions + +Check at minimum: + +- Empty inputs +- Null and undefined handling +- Zero-length arrays or zero-value counts +- Maximum payload sizes +- Largest expected element counts +- Longest expected strings or prompts +- Concurrent operations +- Unexpected events in each state transition + +Flag `GAP` for undefined boundary behavior. + +#### 4.4 Cross-feature interactions + +Check how the feature interacts with existing systems: + +- Chat or session state +- File system state +- Terminal state +- Browser or canvas state +- Persistence +- Existing tool execution flows + +Flag `GAP` for unaddressed side effects or interactions with adjacent features. + +### Step 5: Audit testability + +For every acceptance criterion, spell out: + +1. Input: what must be provided +2. Action: what exact action occurs +3. Expected output: what exact result must be verified + +If any of the three is ambiguous, flag `UNVERIFIABLE`. + +Also evaluate non-functional requirements: + +- Can performance targets be measured with the tools and instrumentation in scope? +- Can success metrics be collected without inventing extra systems not described in the spec? +- Can non-functional requirements be validated before production rollout? + +Flag any ambiguous or non-measurable requirement as `UNVERIFIABLE`. + +### Step 6: Write the audit report + +Write the report to reviews/audit-{spec-base-name}.md using this structure: + +```markdown +# Spec Audit: [Spec Title] + +**Date**: [current date] +**Spec Document**: [path-to-spec-doc] +**Companion Plan**: [path if present, otherwise "None referenced"] +**Branch**: [branch name] +**Pass**: [incremented pass number] + +## Spec Summary + +[2-3 sentences describing the feature scope, primary behavior being specified, and overall audit +posture] + +## Audit Scope + +| Check | Files read | Claims verified | +| --------------------- | -------------------------- | ----------------------- | +| Codebase ground-truth | [N files] | [N claims] | +| Plan cross-validation | [yes/no] | [N checks] | +| Internal consistency | [N sections cross-checked] | [N cross-references] | +| Completeness | [N use cases, N ACs] | [N boundary conditions] | +| Testability | [N ACs evaluated] | [N verified testable] | + +## Verdict: [APPROVED / APPROVED WITH CHANGES / NEEDS REWORK] + +[1-2 sentence justification] + +## Grading + +| Dimension | Grade | Notes | +| ---------------------------------------- | ----- | ----- | +| Correctness (claims match codebase) | [A-F] | | +| Plan alignment (spec covers plan fully) | [A-F] | | +| Internal consistency (no contradictions) | [A-F] | | +| Completeness (no gaps in behavior) | [A-F] | | +| Testability (all ACs are verifiable) | [A-F] | | +| Error coverage (all failures handled) | [A-F] | | + +## Findings + +### Critical (Must fix before implementation) + +| # | Severity | Section | Finding | Evidence | Fix | +| --- | -------- | ------- | --------------- | -------------------------------------- | ------------ | +| 1 | CRITICAL | 3.2 | [What is wrong] | [File:line or section cross-reference] | [How to fix] | + +### Inconsistencies (Must fix) + +| # | Section A | Section B | Mismatch | Fix | +| --- | --------- | ---------- | --------------------- | -------------------------- | +| 1 | 3.1 | Appendix A | [What does not match] | [Which version is correct] | + +### Gaps (Behavior undefined for production scenario) + +| # | Scenario | What is missing | Suggested behavior | +| --- | ------------------------- | ------------------ | --------------------- | +| 1 | [When X happens during Y] | [Missing behavior] | [Proposed resolution] | + +### Unverifiable ACs (Must rewrite) + +| # | AC | Problem | Suggested rewrite | +| --- | ---- | ------------------------- | ------------------ | +| 1 | AC-N | [Why it cannot be tested] | [Testable version] | + +### Drift (Spec claims do not match codebase) + +| # | Spec claim | Actual from codebase | File | Fix | +| --- | ------------------- | -------------------- | -------------- | ----------- | +| 1 | "13 existing tools" | [Actual count] | `path/to/file` | Update spec | + +### Warnings (Clarify before implementation) + +| # | Section | Ambiguity | Suggested clarification | +| --- | ------- | ----------------- | ----------------------- | +| 1 | 3.3 | [What is unclear] | [How to clarify] | + +### Notes (Informational) + +- [Observation 1] +- [Observation 2] + +## Codebase Verification Log + +| Spec claim | File checked | Verified? | Notes | +| ----------------- | ------------------- | --------- | --------------------- | +| [Claim from spec] | `path/to/file:line` | Yes/No | [Details if mismatch] | + +## Plan-Spec Alignment + +| Plan phase | Spec coverage | Gap? | +| ------------------- | ----------------- | ------------------ | +| Phase 1: Runtime | Section 3.1, AC-1 | No | +| Phase 2: MCP Server | Section 3.4, AC-5 | Partial - [detail] | + +## Required Before Implementation + +- [ ] Fix Critical #1: [description] +- [ ] Fix Inconsistency #1: [description] +- [ ] Define Gap #1: [description] +- [ ] Rewrite AC-N: [description] + +## Verdict Details + +### Correctness: [PASS / CONCERNS] + +[Do the spec's codebase claims match reality?] + +### Plan Alignment: [PASS / CONCERNS] + +[Does the spec fully cover the plan?] + +### Internal Consistency: [PASS / CONCERNS] + +[Does the spec contradict itself?] + +### Completeness: [PASS / CONCERNS] + +[Are all likely production scenarios defined?] + +### Testability: [PASS / CONCERNS] + +[Can every acceptance criterion be verified?] + +### Error Coverage: [PASS / CONCERNS] + +[Is every failure mode handled explicitly?] +``` + +If a section has no findings, keep the section and write `None.` instead of deleting it. + +### Step 7: Final output contract + +After writing the report: + +1. Print Audit written to reviews/audit-{spec-base-name}.md +2. Print the `Verdict`, `Grading`, and `Required Before Implementation` sections +3. Print total finding counts in this format: + +```text +Findings: X critical, X inconsistencies, X gaps, X unverifiable, X drift, X warnings, X notes +``` + +## Operating rules + +- Read actual source code. Never trust the spec's claims about the current implementation without + verifying them. +- Be adversarial. Assume the spec has errors until the evidence says otherwise. +- Be concrete. Reference exact sections, file paths, and line numbers whenever possible. +- Do not rubber-stamp. `APPROVED` requires zero critical issues, zero inconsistencies, and zero + gaps. +- Cross-check exhaustively. Two sections that look reasonable in isolation may still contradict + each other. +- Treat testability as mandatory. If you cannot describe the test, the criterion is not done. +- Prefer multiple audit passes. Each pass should reduce open findings and increase confidence. +- Respect the production bar. A spec gap becomes an implementation bug unless it is fixed now. diff --git a/.codex/skills/audit-spec/agents/openai.yaml b/.codex/skills/audit-spec/agents/openai.yaml new file mode 100644 index 000000000..05d3bab7a --- /dev/null +++ b/.codex/skills/audit-spec/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: 'Spec Audit' + short_description: 'Audit behavior specs for production readiness' + default_prompt: 'Use $audit-spec to audit docs/specs/agent-browser-integration-spec.md and write the report to reviews/audit-spec.md.' diff --git a/.codex/skills/spec-generator/SKILL.md b/.codex/skills/spec-generator/SKILL.md new file mode 100644 index 000000000..cc1624913 --- /dev/null +++ b/.codex/skills/spec-generator/SKILL.md @@ -0,0 +1,415 @@ +--- +name: spec-generator +description: Generate a production-grade behavioral specification from an approved plan document. Use when asked to turn a plan doc into a spec sheet, requirements doc, behavioral contract, acceptance-criteria document, or implementation-ready spec for this Tauri 2 + React 19 + Rust codebase. +allowed-tools: + - Read + - Glob + - Grep + - Write + - Bash(git diff:*) + - Bash(git log:*) + - Bash(git branch:*) + - Bash(date:*) + - Bash(wc:*) + - Agent +--- + +# Spec Generator + +Generate a production-grade behavioral specification from an approved plan document. + +The spec defines what the system must do: behavior, contracts, acceptance criteria, constraints, +error handling, and measurable outcomes. The companion plan defines how to build it: +implementation phases, file changes, and rollout order. Together they form the delivery contract. + +## Input and validation + +Require a plan document path argument. + +If the argument is missing, output exactly: + +```text +Usage: /spec +Example: /spec docs/plans/agent-browser-integration.md +``` + +Stop execution. + +If the path does not exist, output exactly: + +```text +Plan document not found at `` +``` + +Stop execution. + +## Context + +Collect these baseline facts before writing: + +- Current branch: `git branch --show-current` +- Current date: use the local system date +- System assumptions: this is a production Tauri 2 + React 19 desktop application with strict + TypeScript, Rust backend code, and user projects at risk if the specification is wrong + +## Critical Rules + +These override everything else. Internalize before proceeding. + +1. Read the code, not just the plan. The plan is a proposal. The codebase is reality. When they + disagree about existing behavior, the codebase wins. +2. Every claim about existing code must be verified. Do not write "13 existing tools" because the + plan says so. Count them in the actual source file. +3. No AI slop. No filler sentences. No "This ensures a seamless experience." Every sentence must + convey information an implementer needs. +4. Testability is mandatory. Every AC must pass the three-part test (Input, Action, Expected + Output). If you can't test it, don't spec it. + +## Step 0: Read the plan and codebase + +### 0.1 Read the plan completely + +Extract: + +1. Feature name and scope +2. All implementation phases +3. All files to create or modify +4. All edge cases and failure paths already called out +5. All deferred or out-of-scope items +6. Any audit history, including verdicts and key design decisions + +### 0.2 Read the actual codebase + +For every existing file the plan references: + +1. Read the file +2. Note the real function names, APIs, schemas, tool names, constants, and current behavior +3. Distinguish behavior that changes from behavior that remains unchanged +4. If a referenced file does not exist, flag it as plan drift. Search for the functionality + elsewhere and note the correct path. + +For every new file the plan proposes: + +1. Determine which module it belongs to +2. Determine what it is expected to export or expose +3. Determine what depends on it + +Do not trust the plan's memory of the current implementation. The codebase is the source of truth +for existing behavior. + +Stop only when you can answer all three questions confidently: + +- What exists today? +- What changes? +- What stays the same? + +If the plan is vague or incomplete (missing phases, unclear scope, no file tables), do not guess. +Put unresolvable gaps in Section 12 (Open Questions) with a safe default and proceed with what is +clear. + +### 0.3 Check for an existing spec + +Search `docs/specs/` for a matching spec. If one exists, read it before writing. Treat the task as +a revision when appropriate, not automatically as a greenfield document. + +## Step 1: Derive the spec structure + +Every generated spec must contain these sections: + +```text +1. Problem Statement +2. Users and Use Cases +3. Expected Behavior +4. Acceptance Criteria +5. Non-Functional Requirements +6. Constraints +7. Error Taxonomy +8. Data Contracts +9. Out of Scope +10. Risks and Mitigations +11. Success Metrics +12. Open Questions +A. Appendix: Full Inventory +B. Appendix: Glossary +``` + +Do not omit, rename, or merge sections unless the user explicitly overrides the format. + +## Step 2: Write each section + +### Section 1: Problem Statement + +Write three subsections, each 2-4 sentences: + +- What's broken: describe the current pain using real code paths and actual workflows +- Why it matters: describe the concrete impact on users or release quality +- What success looks like: one sentence describing the target outcome + +Do not use generic improvement language. Tie the problem statement to actual code and actual usage. + +### Section 2: Users and Use Cases + +Include: + +- A users table describing who uses the feature, in what context, and what pain they have today +- Between 5 and 10 numbered use cases (`UC-N`) + +Each use case must include: + +- A quoted user intent in a blockquote +- Numbered happy-path steps from request to verifiable outcome +- A `Key property` callout stating the invariant demonstrated by the use case + +Coverage requirements: + +- At least one use case must demonstrate failure recovery +- At least one use case must demonstrate backward compatibility when backward compatibility applies +- Every use case must be understandable without reading other sections first + +### Section 3: Expected Behavior + +Organize by subsystem or capability. + +For every behavior, define: + +- Input: what triggers the behavior +- Output: what the system produces +- A behavioral requirements table listing each rule and expected behavior + +For tools, APIs, or commands, include a pre/postcondition table: + +| Tool | Preconditions | Postconditions | Side effects | +| ---- | ------------- | -------------- | ------------ | + +Rules: + +- Specify deterministic behavior where the same input and state yield the same result +- If external state can vary, define behavior per state +- Keep return types consistent with Section 8 data contracts +- Describe current behavior accurately when it already exists instead of inventing replacements + +### Section 4: Acceptance Criteria + +Group criteria by priority: + +- `P0 — Must ship` +- `P1 — Should ship` +- `P2 — Can defer` +- `P3 — Nice to have` + +Every acceptance criterion must include: + +1. Input +2. Action +3. Expected output + +Rules: + +- Every plan phase must map to at least one acceptance criterion +- Every use case must map to at least one acceptance criterion +- Keep the `P0` count to roughly 5-10 unless the user explicitly wants broader scope +- Include one criterion covering quality gates: lint, typecheck, build, and any required Rust checks +- Include one backward-compatibility criterion when backward compatibility is in scope + +### Section 5: Non-Functional Requirements + +Provide tables for: + +- Performance: metric, target, measurement method +- Reliability: requirement, detail +- Security: requirement, detail +- Compatibility: requirement, detail + +Rules: + +- Every performance target must include a feasible measurement method +- Derive targets from the plan or from current known behavior +- If the target depends on instrumentation not in scope, note that in Open Questions + +### Section 6: Constraints + +Document hard boundaries the implementation must not violate. + +Use this format: + +| Constraint | Rationale | +| ---------- | --------- | + +Every constraint must be enforceable and must not contradict expected behavior or acceptance +criteria. + +### Section 7: Error Taxonomy + +Catalog every error category the feature can surface. + +Use this format: + +| Error | Message template | Recovery | +| ----- | ---------------- | -------- | + +Rules: + +- Every failure path in Expected Behavior must appear here +- Every error must have a concrete recovery instruction +- Message templates must use real placeholder syntax such as `{path}` or `{tool}` + +### Section 8: Data Contracts + +Define precise TypeScript interfaces for every consumed or exposed data structure. + +Rules: + +- Do not use `any` +- Avoid `unknown` unless the ambiguity is intentional and explained +- Document when optional fields are present versus absent +- Include ASCII state diagrams for stateful components +- Ensure every field is referenced somewhere else in the spec + +### Section 9: Out of Scope + +List explicit exclusions and why they are deferred. + +Use this format: + +| Item | Why deferred | +| ---- | ------------ | + +Rules: + +- Include every out-of-scope item named by the plan +- Do not list something as out of scope if it already exists and is working today +- Ensure no in-scope requirement depends on an out-of-scope item + +### Section 10: Risks and Mitigations + +Use this format: + +| Risk | Likelihood | Impact | Mitigation | +| ---- | ---------- | ------ | ---------- | + +Rules: + +- Include at least five risks +- Include at least one risk about the plan's core technical bet +- Include at least one risk about integration with existing systems +- Write actionable mitigations + +### Section 11: Success Metrics + +Use this format: + +| Metric | Baseline (current) | Target (post-launch) | How to measure | +| ------ | ------------------ | -------------------- | -------------- | + +Rules: + +- Use real baselines when available +- Mark estimates clearly when exact data does not exist +- Include at least one reliability metric +- Use measurement methods that are feasible in the current environment + +### Section 12: Open Questions + +Use this format: + +| # | Question | Default if unanswered | Impact | +| --- | -------- | --------------------- | ------ | + +Rules: + +- Only include genuinely unresolved questions +- Every question must have a safe default +- Call out when the default could still be wrong + +### Appendix A: Full Inventory + +Enumerate every tool, command, API, or user-facing surface the feature exposes. + +Use a numbered table with: + +- Name +- Status (`Existing`, `Updated`, or `New`) +- Category + +Verify every entry against both the plan and the actual codebase. + +### Appendix B: Glossary + +Define every domain-specific term needed to read the spec without outside context. + +## Step 3: Cross-validate before writing + +Before writing the final file, verify: + +1. Plan coverage: every plan phase has at least one acceptance criterion +2. Use case coverage: every use case is validated by at least one acceptance criterion +3. Error coverage: every failure path in Expected Behavior maps to Error Taxonomy +4. Contract coverage: every return type or payload in Expected Behavior maps to Data Contracts +5. Constraint compliance: no acceptance criterion violates a stated constraint +6. Scope compliance: no expected behavior implements an out-of-scope item +7. Inventory accuracy: Appendix A matches the verified codebase and plan surface +8. Naming consistency: tools, schemas, fields, commands, and file paths match across sections + +If any check fails, fix the spec before writing the file. + +## Step 4: Write the spec + +Write the spec to `docs/specs/{plan-base-name}-spec.md`. + +Example: + +- Plan: `docs/plans/agent-browser-integration.md` +- Output: `docs/specs/agent-browser-integration-spec.md` + +Use this header format: + +```markdown +# Spec: [Feature Name] + +> **Companion plan**: `[path to plan]` +> +> **Status**: Draft +> **Date**: [current date] +> **Scope**: [1-sentence scope] +``` + +## Step 5: Update the documentation index + +If `docs/CLAUDE.md` exists: + +1. Add the `specs/` folder to the folder guide if it is missing +2. Add the new spec file to the `specs/` listing +3. Follow the file's existing naming and listing conventions + +## Step 6: Report completion + +After writing, print: + +1. `Spec written to docs/specs/{filename}` +2. `12 sections, {N} acceptance criteria (P0: {n}, P1: {n}, P2: {n}, P3: {n})` +3. Cross-validation summary: + +```text +Cross-validation: + Plan coverage: {N}/{N} phases covered + Use case coverage: {N}/{N} UCs with ACs + Error coverage: {N}/{N} failure paths with errors + Inventory accuracy: {N} tools verified against codebase +``` + +4. `Run /audit-spec docs/specs/{filename} to validate before implementation.` + +## Operating rules + +- Read the code, not just the plan. When the plan and the current code disagree about existing + behavior, the codebase wins. +- When the plan and the desired future behavior disagree, the approved plan wins. +- Verify every claim about existing code. Do not trust counts, limits, names, or paths without + reading the source. +- Do not write filler. Every sentence must provide implementation-relevant information. +- Do not over-specify behavior outside the plan's scope. +- Do not under-specify production behavior that will definitely occur. +- If a required behavior cannot be stated confidently, put it in Open Questions with a safe default. +- Write only testable acceptance criteria and measurable requirements. +- Assume the spec will be audited against the plan and the codebase line by line. +- Treat specification gaps as production bugs waiting to happen. diff --git a/.codex/skills/spec-generator/agents/openai.yaml b/.codex/skills/spec-generator/agents/openai.yaml new file mode 100644 index 000000000..c6d376bb8 --- /dev/null +++ b/.codex/skills/spec-generator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: 'Spec Generator' + short_description: 'Generate behavior specs from plans' + default_prompt: 'Use $spec-generator to turn docs/plans/agent-browser-integration.md into docs/specs/agent-browser-integration-spec.md.' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 514c83258..0a0909729 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: node-version: ${{ env.NODE_VERSION }} - run: bun install --frozen-lockfile - run: bun run build:frontend + env: + NODE_OPTIONS: '--max-old-space-size=4096' # Only upload artifacts on main branch to save storage # continue-on-error prevents storage quota issues from failing the build diff --git a/.gitignore b/.gitignore index e9edcfedd..f7c06a387 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ dist-ssr # Editor directories and files .vscode/* !.vscode/extensions.json +!.vscode/settings.json +!.vscode/tailwind.css-data.json .idea .DS_Store *.suo diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..2a8ca2580 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "css.customData": [".vscode/tailwind.css-data.json"] +} diff --git a/.vscode/tailwind.css-data.json b/.vscode/tailwind.css-data.json new file mode 100644 index 000000000..063f3c252 --- /dev/null +++ b/.vscode/tailwind.css-data.json @@ -0,0 +1,55 @@ +{ + "version": 1.1, + "atDirectives": [ + { + "name": "@source", + "description": "Tailwind CSS v4: Register additional source paths for class detection.", + "references": [ + { + "name": "Tailwind CSS Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives" + } + ] + }, + { + "name": "@plugin", + "description": "Tailwind CSS v4: Load a Tailwind CSS plugin.", + "references": [ + { + "name": "Tailwind CSS Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives" + } + ] + }, + { + "name": "@custom-variant", + "description": "Tailwind CSS v4: Define a custom variant.", + "references": [ + { + "name": "Tailwind CSS Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives" + } + ] + }, + { + "name": "@theme", + "description": "Tailwind CSS v4: Define design tokens (colors, spacing, fonts, etc.).", + "references": [ + { + "name": "Tailwind CSS Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives" + } + ] + }, + { + "name": "@apply", + "description": "Tailwind CSS: Inline utility classes into custom CSS.", + "references": [ + { + "name": "Tailwind CSS Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives" + } + ] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 26c018c2f..d11596e3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,19 +9,29 @@ Orbit is a modern AI-powered code editor built with Tauri 2 (Rust backend) and R Frontend: - React 19 + TypeScript + Vite + - Bun workspaces for monorepo management + - Tailwind CSS v4 + - Zustand + Immer for state management + - CodeMirror 6 for code editing + - xterm.js for terminal emulation + - Shiki for code block highlighting + - Zod 4 for runtime validation Backend: - Tauri 2 for the desktop app framework + - Rust workspace with multiple crates + - portable-pty for terminal emulation + - Tree-sitter for syntax parsing (planned) ## Package Manager Policy (Bun Only) @@ -74,22 +84,31 @@ Tests live under `apps/agent/src/__tests__/` and `apps/canvas/src/*.test.ts`. Core: - `bun install` — install workspace dependencies. + - `bunx tauri dev` — full app (Vite + Tauri + Rust). + - `bun run dev` — frontend only (Vite, port 5176). + - `bun run build` — production build (includes sidecar). + - `bun run preview` — preview production build. Quality: - `bun run typecheck` — TypeScript `tsc --noEmit`. + - `bun run lint` / `bun run lint:fix` — ESLint (zero warnings). + - `bun run check` — typecheck + lint + tests. + - `bun run ci` — typecheck + lint + tests + Rust checks. + - `./scripts/lint-all.sh` — comprehensive linting (use `--fix` or `--no-test`). Rust: - `cargo build`, `cargo check`, `cargo test`, `cargo clippy`. + - `bun run rust:fmt` / `bun run rust:lint` / `bun run rust:test` (repo shortcuts). ## Development Workflow @@ -113,20 +132,27 @@ Hot reload behavior: ## Coding Style & Naming Conventions - TypeScript is strict (`tsconfig.json`) with explicit types and no unuseds. + - ESLint enforces import ordering and separate `import type` blocks (`import-x/order`). + - Use Prettier for formatting (`bun run format`). + - React components use `PascalCase`; hooks use `use-` prefixes; stores live in `apps/agent/src/stores/`. ## Testing Guidelines - Unit tests use Vitest APIs and live alongside feature areas. + - Use `bun run check` for the repo baseline; use `bun run rust:test` for Rust. + - Prefer adding tests for store logic and message/handler behavior. ## Commit & Pull Request Guidelines - Commit messages follow a Conventional Commits style (e.g., `feat:`, `fix:`). + - No PR template found; include a concise summary and list tests run. + - For UI changes, add screenshots or short clips when behavior is visual. ## CLAUDE.md (Full) @@ -142,19 +168,29 @@ Orbit is a modern AI-powered code editor built with **Tauri 2** (Rust backend) a ### Frontend - **React 19** + TypeScript + Vite + - **Bun** workspaces for monorepo management + - **Tailwind CSS v4** for styling + - **Zustand + Immer** for state management + - **CodeMirror 6** for code editing with custom themes + - **xterm.js** for terminal emulation + - **Shiki** for code block highlighting in chat + - **Zod 4** for runtime validation ### Backend - **Tauri 2** for desktop app framework + - **Rust** workspace with multiple crates + - **portable-pty** for terminal emulation + - Tree-sitter for syntax parsing (planned) ### Package Manager Policy @@ -175,10 +211,10 @@ Orbit is a modern AI-powered code editor built with **Tauri 2** (Rust backend) a **Rules:** -1. **Never use `npm` or `pnpm`** - Always use `bun` -2. **Use `bun run`** for all scripts -3. **Use `bun install`** for installing dependencies -4. **Run tests with `bun test`** +1. **Never use** **`npm`** **or** **`pnpm`** - Always use `bun` +2. **Use** **`bun run`** for all scripts +3. **Use** **`bun install`** for installing dependencies +4. **Run tests with** **`bun test`** ```bash # [ok] CORRECT @@ -345,7 +381,9 @@ Then restart the Tauri app (`Cmd+C` → `bunx tauri dev`). **Why manual rebuild?** - Tauri watches Rust code, not the agent-bridge TypeScript + - The sidecar is a standalone binary (58MB) with embedded Bun runtime + - Located at `target/debug/agent-bridge` in dev mode **Two build outputs:** @@ -364,7 +402,9 @@ bunx tauri build Creates distributable app in `src-tauri/target/release/bundle/`: - **macOS**: `.dmg` and `.app` + - **Windows**: `.exe` and `.msi` + - **Linux**: `.AppImage` and `.deb` ### Frontend-Only Development @@ -433,10 +473,15 @@ await writeTerminal(id, data); The editor (`apps/agent/src/components/editor/CodeMirrorEditor.tsx`) provides: - Full editing with syntax highlighting + - Custom dark/light themes matching app colors + - Language support: TypeScript, JavaScript, Python, Rust, Go, JSON, HTML, CSS, Markdown + - LSP autocompletion integration + - Cmd-S save functionality + - Theme-aware (syncs with app light/dark mode via MutationObserver) ### Theme Colors @@ -469,27 +514,39 @@ All message types in `apps/agent/src/types/protocol.ts` with Zod schemas: ### Frontend → Backend (WebviewMessage) - `message:send` - Send chat message + - `file:read`, `file:write` - File operations + - `terminal:create`, `terminal:write` - Terminal operations + - `agent:stop` - Stop AI generation ### Backend → Frontend (ExtensionMessage) - `agent:chunk`, `agent:complete` - AI responses + - `tool:start`, `tool:end` - Tool execution + - `file:content`, `file:tree:response` - File data + - `terminal:output`, `terminal:created` - Terminal data ## Code Style ### ESLint Rules (enforced) -- **No `any`** - All unsafe operations are errors +- **No** **`any`** - All unsafe operations are errors + - **Explicit return types** on functions + - **Consistent type imports** - Use `import type { }` separately + - **Import order** - External → Types → Internal, alphabetized. Use `bun run lint --fix` if unsure + - **No console.log** - Use structured logger (see below) + - **Strict boolean expressions** - No implicit truthy checks + - **Exhaustive switches** - All cases must be handled ### Structured Logging @@ -511,20 +568,27 @@ logger.error('Error occurred', new Error('fail')); // Always shown with stack **Benefits:** - **Context prefix** - Easily identify source: `[MyComponent] message` + - **Log levels** - Debug messages hidden in production + - **Structured data** - JSON metadata for log aggregation + - **Error handling** - Proper error serialization with stack traces ### Tailwind + Dynamic Styles - **Never use dynamic Tailwind classes** like `` `w-[${value}px]` `` + - Use inline styles for dynamic dimensions: `style={{ width: value }}` + - Static Tailwind classes work normally: `w-px`, `h-[32px]` ### React Patterns - Functional components with explicit `FC` type + - Ternary for conditional rendering + - Props interfaces marked `readonly` ## Module Organization Patterns @@ -655,27 +719,41 @@ pub use types::{FileStatus, GitStatus}; ### Completed - [x] Tauri 2 project setup with Rust workspace + - [x] Monorepo restructure (apps/, crates/common/, commands/) + - [x] CodeMirror 6 editor with custom themes + - [x] File editing with save (Cmd-S) + - [x] Modified indicator on tabs + - [x] Theme switching (light/dark) + - [x] Frontend-backend communication layer + - [x] Terminal with xterm.js + - [x] Git status & operations + - [x] Bun workspace management + - [x] CI/CD with GitHub Actions ### In Progress - [ ] Canvas app implementation + - [ ] Editor app implementation + - [ ] Shared packages extraction ### TODO - [ ] Tree-sitter syntax highlighting + - [ ] LSP/diagnostics integration + - [ ] Advanced search features ## Troubleshooting @@ -687,7 +765,9 @@ pub use types::{FileStatus, GitStatus}; Common symptoms: - "Invalid credentials" or "No credentials found" errors + - Data parsing silently returning `null` + - "Unrecognized keys" or "Invalid input: expected X, received Y" in logs **Root cause:** Schemas using `.strict()` will reject data with extra fields, and type mismatches (e.g., `number` vs `string`) cause validation failures. @@ -695,12 +775,15 @@ Common symptoms: **Where to look:** - `agent-bridge/src/schemas.ts` - Bridge IPC schemas + - `packages/shared-schemas/` - Shared validation schemas **Quick fix pattern:** - For **external data** (APIs, Keychain, SDK responses): Use `.loose()` instead of `.strict()` + - For **type mismatches**: Use `z.union([z.number(), z.string()])` for flexible types + - For **internal data** (your own code): `.strict()` is fine **Example fix (from KeychainCredentialsSchema):** @@ -737,7 +820,7 @@ Common symptoms: | Conversation Context Format | `apps/agent/src/hooks/use-tauri.ts:formatConversationContext()` | `agent-bridge/src/__tests__/conversation-rewind.test.ts` | | Combined Rewind Flow | `apps/agent/src/hooks/use-tauri.ts:conversation:rewind handler` | `agent-bridge/src/__tests__/combined-rewind.test.ts` | -**When adding new tests:** Always add the [warning] TESTED comment to the source function/handler being tested. +**When adding new tests:** Always add the \[warning] TESTED comment to the source function/handler being tested. ### Mandatory Test Requirements @@ -752,7 +835,7 @@ Common symptoms: #### What We Test -| [bad] DO NOT | [ok] DO | +| \[bad] DO NOT | \[ok] DO | | -------------------------------- | ---------------------------------------- | | Mock the Claude SDK | Use REAL Claude API calls | | Test single files in isolation | Test full module integration | @@ -773,19 +856,27 @@ agent-bridge/src/__tests__/ 1. **When creating a new file:** - Add tests to the relevant E2E test file (e.g., `canvas-e2e.test.ts`) + - Tests must exercise the FULL flow through real systems + - Run the complete test suite to verify integration - - Add the [warning] TESTED comment to the source file + + - Add the \[warning] TESTED comment to the source file 2. **When modifying a file that has existing tests:** - Find the E2E test file for that module + - **ADD NEW TESTS** that cover your new functionality + - Tests must verify the new code integrates with existing systems + - Do NOT just run existing tests - that defeats the purpose + - Run the FULL test suite to verify nothing broke 3. **When modifying a file without existing tests:** - Create E2E tests if the module doesn't have them + - Tests must cover the full integration path #### Real Integration Test Example @@ -826,10 +917,15 @@ it('should route simple requests to fast path via real session', async () => { #### Test Checklist Before PR - [ ] Added tests for ALL new functionality + - [ ] Tests use REAL systems (no mocks for core functionality) + - [ ] Tests verify FULL integration flow + - [ ] Tests cover error cases and cleanup + - [ ] Ran complete test suite: `cd agent-bridge && bun test` + - [ ] All tests pass with real API calls #### Running Tests Locally (IMPORTANT) @@ -852,6 +948,7 @@ bun test Tests use `describe.skipIf(process.env.GITHUB_ACTIONS === 'true')` to: - **Run locally**: OAuth from Claude Code CLI keychain is available + - **Skip in CI**: GitHub Actions has no OAuth access, tests would fail with "No credentials found" **If tests fail locally with "No credentials found":** @@ -953,10 +1050,15 @@ The following CSS properties trigger GPU compositing issues that result in momen #### Quick Checklist for Tauri-Compatible CSS - [ ] No `backdrop-filter: blur()` on interactive elements + - [ ] No `color-mix()` function - use CSS variables or `rgba()` instead + - [ ] No `transition` on elements inside ReactFlow's transformed viewport + - [ ] No `animation` on elements inside ReactFlow's transformed viewport + - [ ] Avoid combining `opacity` transitions with other transforms + - [ ] Test in Tauri app, not just browser (blur won't appear in browser) **Note:** `contain: layout style paint` and `will-change: transform` do NOT fix the blur issue. The only solution for elements inside ReactFlow's viewport is to completely remove transitions and animations. @@ -973,9 +1075,9 @@ The following CSS properties trigger GPU compositing issues that result in momen **Last audited:** January 2025 -| Package | Severity | CVE | Status | Notes | -| --------------------------- | -------- | ------------- | -------------------------- | ----------------------------------------------- | -| `@modelcontextprotocol/sdk` | High | CVE-2026-0621 | [pending] Waiting upstream | ReDoS in UriTemplate class. No patch available. | +| Package | Severity | CVE | Status | Notes | +| --------------------------- | -------- | ------------- | --------------------------- | ----------------------------------------------- | +| `@modelcontextprotocol/sdk` | High | CVE-2026-0621 | \[pending] Waiting upstream | ReDoS in UriTemplate class. No patch available. | #### MCP SDK ReDoS (CVE-2026-0621) @@ -1032,12 +1134,15 @@ All color variables are defined in `apps/agent/src/globals.css`: ### Rules -1. **NEVER define `:root` color variables in canvas globals.css** - They will override agent colors +1. **NEVER define** **`:root`** **color variables in canvas globals.css** - They will override agent colors 2. **Canvas uses agent's variables** - e.g., `var(--background)`, `var(--card)`, `var(--primary)` 3. **Canvas globals.css contains only:** - Tailwind `@theme` mappings (pointing to agent's variables) + - ReactFlow style overrides (`.react-flow__*`) + - Scrollbar styling + - Base layout (html, body, #root) ### Adding New Colors @@ -1072,7 +1177,9 @@ The Canvas app is embedded within the Agent app as a mode/tab (not a separate Ta ### Canvas Libraries - **@xyflow/react** (ReactFlow) - Node-based canvas + - **@codesandbox/sandpack-react** - Code playground integration + - **Zustand** - State management (`apps/canvas/src/stores/`) ### Canvas Directory Structure @@ -1099,10 +1206,16 @@ apps/canvas/src/ - **Migrated from pnpm to Bun** - All package management now uses Bun for faster installs and unified tooling - Removed `pnpm-workspace.yaml` - workspaces defined in `package.json` + - Removed `pnpm-lock.yaml` - replaced by `bun.lockb` + - Updated all scripts, CI workflows, and husky hooks to use `bun` + - Added comprehensive `lint-all.sh` script for running all checks + - **Added comprehensive audit** - Aligned all linting, TypeScript, and CI checks across the monorepo + - **Documented module organization patterns** - Barrel pattern for frontend, explicit paths for Rust backend - Frontend: Every folder with multiple files gets an `index.ts` barrel + - Backend: Tauri commands use explicit paths, shared crates use selective re-exports diff --git a/Agent-backend/.editorconfig b/Agent-backend/.editorconfig new file mode 100644 index 000000000..aada95f26 --- /dev/null +++ b/Agent-backend/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +end_of_line = lf +indent_style = space +indent_size = 2 +max_line_length = 80 diff --git a/Agent-backend/.gitignore b/Agent-backend/.gitignore new file mode 100644 index 000000000..bf78c046d --- /dev/null +++ b/Agent-backend/.gitignore @@ -0,0 +1,30 @@ +.DS_Store +node_modules +.worktrees +.sst +.env +.idea +.vscode +.codex +*~ +playground +tmp +dist +ts-dist +.turbo +**/.serena +.serena/ +/result +refs +Session.vim +opencode.json +a.out +target +.scripts +.direnv/ + +# Local dev files +opencode-dev +logs/ +*.bun-build +tsconfig.tsbuildinfo diff --git a/Agent-backend/.husky/CLAUDE.md b/Agent-backend/.husky/CLAUDE.md new file mode 100644 index 000000000..44a482198 --- /dev/null +++ b/Agent-backend/.husky/CLAUDE.md @@ -0,0 +1,21 @@ +# .husky + +> **Path:** `Agent-backend/.husky/` + +## Purpose + +Git hook scripts managed by Husky. Contains a `pre-push` hook that runs before pushing to remote. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ---------- | ------------------ | +| Orbit Desktop (SDK) | `not used` | Upstream git hooks | +| Orbit CLI | `not used` | Same | + +## Files + +| File | Purpose | +| ---------- | ------------------------ | +| `pre-push` | Pre-push validation hook | +| `_/` | Husky internals | diff --git a/Agent-backend/.husky/pre-push b/Agent-backend/.husky/pre-push new file mode 100755 index 000000000..5d3cc5341 --- /dev/null +++ b/Agent-backend/.husky/pre-push @@ -0,0 +1,20 @@ +#!/bin/sh +set -e +# Check if bun version matches package.json +# keep in sync with packages/script/src/index.ts semver qualifier +bun -e ' +import { semver } from "bun"; +const pkg = await Bun.file("package.json").json(); +const expectedBunVersion = pkg.packageManager?.split("@")[1]; +if (!expectedBunVersion) { + throw new Error("packageManager field not found in root package.json"); +} +const expectedBunVersionRange = `^${expectedBunVersion}`; +if (!semver.satisfies(process.versions.bun, expectedBunVersionRange)) { + throw new Error(`This script requires bun@${expectedBunVersionRange}, but you are using bun@${process.versions.bun}`); +} +if (process.versions.bun !== expectedBunVersion) { + console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`); +} +' +bun typecheck diff --git a/Agent-backend/.opencode/.gitignore b/Agent-backend/.opencode/.gitignore new file mode 100644 index 000000000..00bfdfda2 --- /dev/null +++ b/Agent-backend/.opencode/.gitignore @@ -0,0 +1,3 @@ +plans/ +bun.lock +package.json diff --git a/Agent-backend/.opencode/CLAUDE.md b/Agent-backend/.opencode/CLAUDE.md new file mode 100644 index 000000000..39f5cde24 --- /dev/null +++ b/Agent-backend/.opencode/CLAUDE.md @@ -0,0 +1,39 @@ +# .opencode + +> **Path:** `Agent-backend/.opencode/` + +## Purpose + +OpenCode project-level configuration — custom agent definitions, slash commands, glossary translations, themes, and custom tools. This is how the OpenCode project itself extends its own agent with project-specific behaviors. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Demonstrates how to customize agents, commands, and tools per-project. Orbit's `.orbit/` config directory follows the same pattern. | +| Orbit CLI | `reference` | Same — the CLI reads `.opencode/` for project-specific customization | + +## Key Directories + +| Directory | Purpose | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `agent/` | Custom agent definitions (4 agents): `docs.md` (documentation agent), `duplicate-pr.md` (PR dedup), `translator.md` (i18n translation), `triage.md` (issue triage) | +| `command/` | Custom slash commands (7 commands): `ai-deps.md`, `audit-plan.md`, `commit.md`, `issues.md`, `learn.md`, `rmslop.md`, `spellcheck.md` | +| `glossary/` | Translation glossaries — 17 language files (ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, tr, zh-cn, zh-tw) + README | +| `themes/` | Custom themes: `mytheme.json` | +| `tool/` | Custom tools (2): `github-pr-search.ts`/`.txt`, `github-triage.ts`/`.txt` — each with implementation + prompt pair | + +## Key Files + +| File | Purpose | +| ---------------- | ------------------------------------ | +| `opencode.jsonc` | Project configuration (JSONC format) | +| `package.json` | Dependencies for custom tools | +| `env.d.ts` | Environment type declarations | + +## Notes + +- **Custom tools follow the same `.ts` + `.txt` pattern** as built-in tools in `packages/opencode/src/tool/`. The `.ts` is the implementation, the `.txt` is the system prompt fragment. +- **17 translation glossaries** — used by the `translator` agent to maintain consistent terminology across i18n translations. +- **`rmslop.md`** — a custom command to remove AI-generated filler text ("slop") from code and docs. Shows how project-specific workflows can be encoded as slash commands. +- **This directory is the config format that Orbit's `.orbit/` directory mirrors** — same structure for agents, commands, and tools. diff --git a/Agent-backend/.opencode/agent/CLAUDE.md b/Agent-backend/.opencode/agent/CLAUDE.md new file mode 100644 index 000000000..710651ffb --- /dev/null +++ b/Agent-backend/.opencode/agent/CLAUDE.md @@ -0,0 +1,23 @@ +# agent + +> **Path:** `Agent-backend/.opencode/agent/` + +## Purpose + +Custom agent definitions for the OpenCode project. Each markdown file defines a specialized AI agent with its own system prompt, model selection, tool access, and behavioral rules. These agents are project-specific extensions loaded by the OpenCode runtime. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ----------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Demonstrates the agent definition format — Orbit's `.orbit/agent/` directory follows the same pattern | +| Orbit CLI | `reference` | Same — the CLI loads agents from `.orbit/agent/` using this format | + +## Files + +| File | Purpose | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docs.md` | Documentation writer agent — enforces concise style, section dividers, imperative headings, no semicolons in code snippets | +| `duplicate-pr.md` | PR deduplication agent — finds and closes duplicate PRs | +| `translator.md` | i18n translation agent — translates docs across 17 locales with glossary enforcement | +| `triage.md` | Issue triage agent — auto-labels and assigns GitHub issues based on ownership rules. Uses `minimax-m2.5` model, restricted to `github-triage` tool only | diff --git a/Agent-backend/.opencode/agent/docs.md b/Agent-backend/.opencode/agent/docs.md new file mode 100644 index 000000000..21cfc6a16 --- /dev/null +++ b/Agent-backend/.opencode/agent/docs.md @@ -0,0 +1,34 @@ +--- +description: ALWAYS use this when writing docs +color: "#38A3EE" +--- + +You are an expert technical documentation writer + +You are not verbose + +Use a relaxed and friendly tone + +The title of the page should be a word or a 2-3 word phrase + +The description should be one short line, should not start with "The", should +avoid repeating the title of the page, should be 5-10 words long + +Chunks of text should not be more than 2 sentences long + +Each section is separated by a divider of 3 dashes + +The section titles are short with only the first letter of the word capitalized + +The section titles are in the imperative mood + +The section titles should not repeat the term used in the page title, for +example, if the page title is "Models", avoid using a section title like "Add +new models". This might be unavoidable in some cases, but try to avoid it. + +Check out the /packages/web/src/content/docs/docs/index.mdx as an example. + +For JS or TS code snippets remove trailing semicolons and any trailing commas +that might not be needed. + +If you are making a commit prefix the commit message with `docs:` diff --git a/Agent-backend/.opencode/agent/duplicate-pr.md b/Agent-backend/.opencode/agent/duplicate-pr.md new file mode 100644 index 000000000..c9c932ef7 --- /dev/null +++ b/Agent-backend/.opencode/agent/duplicate-pr.md @@ -0,0 +1,26 @@ +--- +mode: primary +hidden: true +model: opencode/claude-haiku-4-5 +color: "#E67E22" +tools: + "*": false + "github-pr-search": true +--- + +You are a duplicate PR detection agent. When a PR is opened, your job is to search for potentially duplicate or related open PRs. + +Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature. + +IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself. + +Search using keywords from the PR title and description. Try multiple searches with different relevant terms. + +If you find potential duplicates: + +- List them with their titles and URLs +- Briefly explain why they might be related + +If no duplicates are found, say so clearly. BUT ONLY SAY "No duplicate PRs found" (don't say anything else if no dups) + +Keep your response concise and actionable. diff --git a/Agent-backend/.opencode/agent/translator.md b/Agent-backend/.opencode/agent/translator.md new file mode 100644 index 000000000..263afbe9b --- /dev/null +++ b/Agent-backend/.opencode/agent/translator.md @@ -0,0 +1,900 @@ +--- +description: Translate content for a specified locale while preserving technical terms +mode: subagent +model: opencode/gemini-3-pro +--- + +You are a professional translator and localization specialist. + +Translate the user's content into the requested target locale (language + region, e.g. fr-FR, de-DE). + +Requirements: + +- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure). +- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks. +- Also preserve every term listed in the Do-Not-Translate glossary below. +- Also apply locale-specific guidance from `.opencode/glossary/.md` when available (for example, `zh-cn.md`). +- Do not modify fenced code blocks. +- Output ONLY the translation (no commentary). + +If the target locale is missing, ask the user to provide it. +If no locale-specific glossary exists, use the global glossary only. + +--- + +# Locale-Specific Glossaries + +When a locale glossary exists, use it to: + +- Apply preferred wording for recurring UI/docs terms in that locale +- Preserve locale-specific do-not-translate terms and casing decisions +- Prefer natural phrasing over literal translation when the locale file calls it out +- If the repo uses a locale alias slug, apply that file too (for example, `pt-BR` maps to `br.md` in this repo) + +Locale guidance does not override code/command preservation rules or the global Do-Not-Translate glossary below. + +--- + +# Do-Not-Translate Terms (OpenCode Docs) + +Generated from: `packages/web/src/content/docs/*.mdx` (default English docs) +Generated on: 2026-02-10 + +Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation). + +General rules (verbatim, even if not listed below): + +- Anything inside inline code (single backticks) or fenced code blocks (triple backticks) +- MDX/JS code in docs: `import ... from "..."`, component tags, identifiers +- CLI commands, flags, config keys/values, file paths, URLs/domains, and env vars + +## Proper nouns and product names + +Additional (not reliably captured via link text): + +```text +Astro +Bun +Chocolatey +Cursor +Docker +Git +GitHub Actions +GitLab CI +GNOME Terminal +Homebrew +Mise +Neovim +Node.js +npm +Obsidian +opencode +opencode-ai +Paru +pnpm +ripgrep +Scoop +SST +Starlight +Visual Studio Code +VS Code +VSCodium +Windsurf +Windows Terminal +Yarn +Zellij +Zed +anomalyco +``` + +Extracted from link labels in the English docs (review and prune as desired): + +```text +@openspoon/subtask2 +302.AI console +ACP progress report +Agent Client Protocol +Agent Skills +Agentic +AGENTS.md +AI SDK +Alacritty +Anthropic +Anthropic's Data Policies +Atom One +Avante.nvim +Ayu +Azure AI Foundry +Azure portal +Baseten +built-in GITHUB_TOKEN +Bun.$ +Catppuccin +Cerebras console +ChatGPT Plus or Pro +Cloudflare dashboard +CodeCompanion.nvim +CodeNomad +Configuring Adapters: Environment Variables +Context7 MCP server +Cortecs console +Deep Infra dashboard +DeepSeek console +Duo Agent Platform +Everforest +Fireworks AI console +Firmware dashboard +Ghostty +GitLab CLI agents docs +GitLab docs +GitLab User Settings > Access Tokens +Granular Rules (Object Syntax) +Grep by Vercel +Groq console +Gruvbox +Helicone +Helicone documentation +Helicone Header Directory +Helicone's Model Directory +Hugging Face Inference Providers +Hugging Face settings +install WSL +IO.NET console +JetBrains IDE +Kanagawa +Kitty +MiniMax API Console +Models.dev +Moonshot AI console +Nebius Token Factory console +Nord +OAuth +Ollama integration docs +OpenAI's Data Policies +OpenChamber +OpenCode +OpenCode config +OpenCode Config +OpenCode TUI with the opencode theme +OpenCode Web - Active Session +OpenCode Web - New Session +OpenCode Web - See Servers +OpenCode Zen +OpenCode-Obsidian +OpenRouter dashboard +OpenWork +OVHcloud panel +Pro+ subscription +SAP BTP Cockpit +Scaleway Console IAM settings +Scaleway Generative APIs +SDK documentation +Sentry MCP server +shell API +Together AI console +Tokyonight +Unified Billing +Venice AI console +Vercel dashboard +WezTerm +Windows Subsystem for Linux (WSL) +WSL +WSL (Windows Subsystem for Linux) +WSL extension +xAI console +Z.AI API console +Zed +ZenMux dashboard +Zod +``` + +## Acronyms and initialisms + +```text +ACP +AGENTS +AI +AI21 +ANSI +API +AST +AWS +BTP +CD +CDN +CI +CLI +CMD +CORS +DEBUG +EKS +ERROR +FAQ +GLM +GNOME +GPT +HTML +HTTP +HTTPS +IAM +ID +IDE +INFO +IO +IP +IRSA +JS +JSON +JSONC +K2 +LLM +LM +LSP +M2 +MCP +MR +NET +NPM +NTLM +OIDC +OS +PAT +PATH +PHP +PR +PTY +README +RFC +RPC +SAP +SDK +SKILL +SSE +SSO +TS +TTY +TUI +UI +URL +US +UX +VCS +VPC +VPN +VS +WARN +WSL +X11 +YAML +``` + +## Code identifiers used in prose (CamelCase, mixedCase) + +```text +apiKey +AppleScript +AssistantMessage +baseURL +BurntSushi +ChatGPT +ClangFormat +CodeCompanion +CodeNomad +DeepSeek +DefaultV2 +FileContent +FileDiff +FileNode +fineGrained +FormatterStatus +GitHub +GitLab +iTerm2 +JavaScript +JetBrains +macOS +mDNS +MiniMax +NeuralNomadsAI +NickvanDyke +NoeFabris +OpenAI +OpenAPI +OpenChamber +OpenCode +OpenRouter +OpenTUI +OpenWork +ownUserPermissions +PowerShell +ProviderAuthAuthorization +ProviderAuthMethod +ProviderInitError +SessionStatus +TabItem +tokenType +ToolIDs +ToolList +TypeScript +typesUrl +UserMessage +VcsInfo +WebView2 +WezTerm +xAI +ZenMux +``` + +## OpenCode CLI commands (as shown in docs) + +```text +opencode +opencode [project] +opencode /path/to/project +opencode acp +opencode agent [command] +opencode agent create +opencode agent list +opencode attach [url] +opencode attach http://10.20.30.40:4096 +opencode attach http://localhost:4096 +opencode auth [command] +opencode auth list +opencode auth login +opencode auth logout +opencode auth ls +opencode export [sessionID] +opencode github [command] +opencode github install +opencode github run +opencode import +opencode import https://opncd.ai/s/abc123 +opencode import session.json +opencode mcp [command] +opencode mcp add +opencode mcp auth [name] +opencode mcp auth list +opencode mcp auth ls +opencode mcp auth my-oauth-server +opencode mcp auth sentry +opencode mcp debug +opencode mcp debug my-oauth-server +opencode mcp list +opencode mcp logout [name] +opencode mcp logout my-oauth-server +opencode mcp ls +opencode models --refresh +opencode models [provider] +opencode models anthropic +opencode run [message..] +opencode run Explain the use of context in Go +opencode serve +opencode serve --cors http://localhost:5173 --cors https://app.example.com +opencode serve --hostname 0.0.0.0 --port 4096 +opencode serve [--port ] [--hostname ] [--cors ] +opencode session [command] +opencode session list +opencode session delete +opencode stats +opencode uninstall +opencode upgrade +opencode upgrade [target] +opencode upgrade v0.1.48 +opencode web +opencode web --cors https://example.com +opencode web --hostname 0.0.0.0 +opencode web --mdns +opencode web --mdns --mdns-domain myproject.local +opencode web --port 4096 +opencode web --port 4096 --hostname 0.0.0.0 +opencode.server.close() +``` + +## Slash commands and routes + +```text +/agent +/auth/:id +/clear +/command +/config +/config/providers +/connect +/continue +/doc +/editor +/event +/experimental/tool?provider=

&model= +/experimental/tool/ids +/export +/file?path= +/file/content?path=

+/file/status +/find?pattern= +/find/file +/find/file?query= +/find/symbol?query= +/formatter +/global/event +/global/health +/help +/init +/instance/dispose +/log +/lsp +/mcp +/mnt/ +/mnt/c/ +/mnt/d/ +/models +/oc +/opencode +/path +/project +/project/current +/provider +/provider/{id}/oauth/authorize +/provider/{id}/oauth/callback +/provider/auth +/q +/quit +/redo +/resume +/session +/session/:id +/session/:id/abort +/session/:id/children +/session/:id/command +/session/:id/diff +/session/:id/fork +/session/:id/init +/session/:id/message +/session/:id/message/:messageID +/session/:id/permissions/:permissionID +/session/:id/prompt_async +/session/:id/revert +/session/:id/share +/session/:id/shell +/session/:id/summarize +/session/:id/todo +/session/:id/unrevert +/session/status +/share +/summarize +/theme +/tui +/tui/append-prompt +/tui/clear-prompt +/tui/control/next +/tui/control/response +/tui/execute-command +/tui/open-help +/tui/open-models +/tui/open-sessions +/tui/open-themes +/tui/show-toast +/tui/submit-prompt +/undo +/Users/username +/Users/username/projects/* +/vcs +``` + +## CLI flags and short options + +```text +--agent +--attach +--command +--continue +--cors +--cwd +--days +--dir +--dry-run +--event +--file +--force +--fork +--format +--help +--hostname +--hostname 0.0.0.0 +--keep-config +--keep-data +--log-level +--max-count +--mdns +--mdns-domain +--method +--model +--models +--port +--print-logs +--project +--prompt +--refresh +--session +--share +--title +--token +--tools +--verbose +--version +--wait + +-c +-d +-f +-h +-m +-n +-s +-v +``` + +## Environment variables + +```text +AI_API_URL +AI_FLOW_CONTEXT +AI_FLOW_EVENT +AI_FLOW_INPUT +AICORE_DEPLOYMENT_ID +AICORE_RESOURCE_GROUP +AICORE_SERVICE_KEY +ANTHROPIC_API_KEY +AWS_ACCESS_KEY_ID +AWS_BEARER_TOKEN_BEDROCK +AWS_PROFILE +AWS_REGION +AWS_ROLE_ARN +AWS_SECRET_ACCESS_KEY +AWS_WEB_IDENTITY_TOKEN_FILE +AZURE_COGNITIVE_SERVICES_RESOURCE_NAME +AZURE_RESOURCE_NAME +CI_PROJECT_DIR +CI_SERVER_FQDN +CI_WORKLOAD_REF +CLOUDFLARE_ACCOUNT_ID +CLOUDFLARE_API_TOKEN +CLOUDFLARE_GATEWAY_ID +CONTEXT7_API_KEY +GITHUB_TOKEN +GITLAB_AI_GATEWAY_URL +GITLAB_HOST +GITLAB_INSTANCE_URL +GITLAB_OAUTH_CLIENT_ID +GITLAB_TOKEN +GITLAB_TOKEN_OPENCODE +GOOGLE_APPLICATION_CREDENTIALS +GOOGLE_CLOUD_PROJECT +HTTP_PROXY +HTTPS_PROXY +K2_ +MY_API_KEY +MY_ENV_VAR +MY_MCP_CLIENT_ID +MY_MCP_CLIENT_SECRET +NO_PROXY +NODE_ENV +NODE_EXTRA_CA_CERTS +NPM_AUTH_TOKEN +OC_ALLOW_WAYLAND +OPENCODE_API_KEY +OPENCODE_AUTH_JSON +OPENCODE_AUTO_SHARE +OPENCODE_CLIENT +OPENCODE_CONFIG +OPENCODE_CONFIG_CONTENT +OPENCODE_CONFIG_DIR +OPENCODE_DISABLE_AUTOCOMPACT +OPENCODE_DISABLE_AUTOUPDATE +OPENCODE_DISABLE_CLAUDE_CODE +OPENCODE_DISABLE_CLAUDE_CODE_PROMPT +OPENCODE_DISABLE_CLAUDE_CODE_SKILLS +OPENCODE_DISABLE_DEFAULT_PLUGINS +OPENCODE_DISABLE_FILETIME_CHECK +OPENCODE_DISABLE_LSP_DOWNLOAD +OPENCODE_DISABLE_MODELS_FETCH +OPENCODE_DISABLE_PRUNE +OPENCODE_DISABLE_TERMINAL_TITLE +OPENCODE_ENABLE_EXA +OPENCODE_ENABLE_EXPERIMENTAL_MODELS +OPENCODE_EXPERIMENTAL +OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS +OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT +OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER +OPENCODE_EXPERIMENTAL_EXA +OPENCODE_EXPERIMENTAL_FILEWATCHER +OPENCODE_EXPERIMENTAL_ICON_DISCOVERY +OPENCODE_EXPERIMENTAL_LSP_TOOL +OPENCODE_EXPERIMENTAL_LSP_TY +OPENCODE_EXPERIMENTAL_MARKDOWN +OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX +OPENCODE_EXPERIMENTAL_OXFMT +OPENCODE_EXPERIMENTAL_PLAN_MODE +OPENCODE_ENABLE_QUESTION_TOOL +OPENCODE_FAKE_VCS +OPENCODE_GIT_BASH_PATH +OPENCODE_MODEL +OPENCODE_MODELS_URL +OPENCODE_PERMISSION +OPENCODE_PORT +OPENCODE_SERVER_PASSWORD +OPENCODE_SERVER_USERNAME +PROJECT_ROOT +RESOURCE_NAME +RUST_LOG +VARIABLE_NAME +VERTEX_LOCATION +XDG_CONFIG_HOME +``` + +## Package/module identifiers + +```text +../../../config.mjs +@astrojs/starlight/components +@opencode-ai/plugin +@opencode-ai/sdk +path +shescape +zod + +@ +@ai-sdk/anthropic +@ai-sdk/cerebras +@ai-sdk/google +@ai-sdk/openai +@ai-sdk/openai-compatible +@File#L37-42 +@modelcontextprotocol/server-everything +@opencode +``` + +## GitHub owner/repo slugs referenced in docs + +```text +24601/opencode-zellij-namer +angristan/opencode-wakatime +anomalyco/opencode +apps/opencode-agent +athal7/opencode-devcontainers +awesome-opencode/awesome-opencode +backnotprop/plannotator +ben-vargas/ai-sdk-provider-opencode-sdk +btriapitsyn/openchamber +BurntSushi/ripgrep +Cluster444/agentic +code-yeongyu/oh-my-opencode +darrenhinde/opencode-agents +different-ai/opencode-scheduler +different-ai/openwork +features/copilot +folke/tokyonight.nvim +franlol/opencode-md-table-formatter +ggml-org/llama.cpp +ghoulr/opencode-websearch-cited.git +H2Shami/opencode-helicone-session +hosenur/portal +jamesmurdza/daytona +jenslys/opencode-gemini-auth +JRedeker/opencode-morph-fast-apply +JRedeker/opencode-shell-strategy +kdcokenny/ocx +kdcokenny/opencode-background-agents +kdcokenny/opencode-notify +kdcokenny/opencode-workspace +kdcokenny/opencode-worktree +login/device +mohak34/opencode-notifier +morhetz/gruvbox +mtymek/opencode-obsidian +NeuralNomadsAI/CodeNomad +nick-vi/opencode-type-inject +NickvanDyke/opencode.nvim +NoeFabris/opencode-antigravity-auth +nordtheme/nord +numman-ali/opencode-openai-codex-auth +olimorris/codecompanion.nvim +panta82/opencode-notificator +rebelot/kanagawa.nvim +remorses/kimaki +sainnhe/everforest +shekohex/opencode-google-antigravity-auth +shekohex/opencode-pty.git +spoons-and-mirrors/subtask2 +sudo-tee/opencode.nvim +supermemoryai/opencode-supermemory +Tarquinen/opencode-dynamic-context-pruning +Th3Whit3Wolf/one-nvim +upstash/context7 +vtemian/micode +vtemian/octto +yetone/avante.nvim +zenobi-us/opencode-plugin-template +zenobi-us/opencode-skillful +``` + +## Paths, filenames, globs, and URLs + +```text +./.opencode/themes/*.json +.//storage/ +./config/#custom-directory +./global/storage/ +.agents/skills/*/SKILL.md +.agents/skills//SKILL.md +.clang-format +.claude +.claude/skills +.claude/skills/*/SKILL.md +.claude/skills//SKILL.md +.env +.github/workflows/opencode.yml +.gitignore +.gitlab-ci.yml +.ignore +.NET SDK +.npmrc +.ocamlformat +.opencode +.opencode/ +.opencode/agents/ +.opencode/commands/ +.opencode/commands/test.md +.opencode/modes/ +.opencode/plans/*.md +.opencode/plugins/ +.opencode/skills//SKILL.md +.opencode/skills/git-release/SKILL.md +.opencode/tools/ +.well-known/opencode +{ type: "raw" \| "patch", content: string } +{file:path/to/file} +**/*.js +%USERPROFILE%/intelephense/license.txt +%USERPROFILE%\.cache\opencode +%USERPROFILE%\.config\opencode\opencode.jsonc +%USERPROFILE%\.config\opencode\plugins +%USERPROFILE%\.local\share\opencode +%USERPROFILE%\.local\share\opencode\log +/.opencode/themes/*.json +/ +/.opencode/plugins/ +~ +~/... +~/.agents/skills/*/SKILL.md +~/.agents/skills//SKILL.md +~/.aws/credentials +~/.bashrc +~/.cache/opencode +~/.cache/opencode/node_modules/ +~/.claude/CLAUDE.md +~/.claude/skills/ +~/.claude/skills/*/SKILL.md +~/.claude/skills//SKILL.md +~/.config/opencode +~/.config/opencode/AGENTS.md +~/.config/opencode/agents/ +~/.config/opencode/commands/ +~/.config/opencode/modes/ +~/.config/opencode/opencode.json +~/.config/opencode/opencode.jsonc +~/.config/opencode/plugins/ +~/.config/opencode/skills/*/SKILL.md +~/.config/opencode/skills//SKILL.md +~/.config/opencode/themes/*.json +~/.config/opencode/tools/ +~/.config/zed/settings.json +~/.local/share +~/.local/share/opencode/ +~/.local/share/opencode/auth.json +~/.local/share/opencode/log/ +~/.local/share/opencode/mcp-auth.json +~/.local/share/opencode/opencode.jsonc +~/.npmrc +~/.zshrc +~/code/ +~/Library/Application Support +~/projects/* +~/projects/personal/ +${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts +$HOME/intelephense/license.txt +$HOME/projects/* +$XDG_CONFIG_HOME/opencode/themes/*.json +agent/ +agents/ +build/ +commands/ +dist/ +http://:4096 +http://127.0.0.1:8080/callback +http://localhost: +http://localhost:4096 +http://localhost:4096/doc +https://app.example.com +https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/ +https://opencode.ai/zen/v1/chat/completions +https://opencode.ai/zen/v1/messages +https://opencode.ai/zen/v1/models/gemini-3-flash +https://opencode.ai/zen/v1/models/gemini-3-pro +https://opencode.ai/zen/v1/responses +https://RESOURCE_NAME.openai.azure.com/ +laravel/pint +log/ +model: "anthropic/claude-sonnet-4-5" +modes/ +node_modules/ +openai/gpt-4.1 +opencode.ai/config.json +opencode/ +opencode/gpt-5.1-codex +opencode/gpt-5.2-codex +opencode/kimi-k2 +openrouter/google/gemini-2.5-flash +opncd.ai/s/ +packages/*/AGENTS.md +plugins/ +project/ +provider_id/model_id +provider/model +provider/model-id +rm -rf ~/.cache/opencode +skills/ +skills/*/SKILL.md +src/**/*.ts +themes/ +tools/ +``` + +## Keybind strings + +```text +alt+b +Alt+Ctrl+K +alt+d +alt+f +Cmd+Esc +Cmd+Option+K +Cmd+Shift+Esc +Cmd+Shift+G +Cmd+Shift+P +ctrl+a +ctrl+b +ctrl+d +ctrl+e +Ctrl+Esc +ctrl+f +ctrl+g +ctrl+k +Ctrl+Shift+Esc +Ctrl+Shift+P +ctrl+t +ctrl+u +ctrl+w +ctrl+x +DELETE +Shift+Enter +WIN+R +``` + +## Model ID strings referenced + +```text +{env:OPENCODE_MODEL} +anthropic/claude-3-5-sonnet-20241022 +anthropic/claude-haiku-4-20250514 +anthropic/claude-haiku-4-5 +anthropic/claude-sonnet-4-20250514 +anthropic/claude-sonnet-4-5 +gitlab/duo-chat-haiku-4-5 +lmstudio/google/gemma-3n-e4b +openai/gpt-4.1 +openai/gpt-5 +opencode/gpt-5.1-codex +opencode/gpt-5.2-codex +opencode/kimi-k2 +openrouter/google/gemini-2.5-flash +``` diff --git a/Agent-backend/.opencode/agent/triage.md b/Agent-backend/.opencode/agent/triage.md new file mode 100644 index 000000000..a77b92737 --- /dev/null +++ b/Agent-backend/.opencode/agent/triage.md @@ -0,0 +1,140 @@ +--- +mode: primary +hidden: true +model: opencode/minimax-m2.5 +color: "#44BA81" +tools: + "*": false + "github-triage": true +--- + +You are a triage agent responsible for triaging github issues. + +Use your github-triage tool to triage issues. + +This file is the source of truth for ownership/routing rules. + +## Labels + +### windows + +Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows. + +- Use if they mention WSL too + +#### perf + +Performance-related issues: + +- Slow performance +- High RAM usage +- High CPU usage + +**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness. + +#### desktop + +Desktop app issues: + +- `opencode web` command +- The desktop app itself + +**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues. + +#### nix + +**Only** add if the issue explicitly mentions nix. + +If the issue does not mention nix, do not add nix. + +If the issue mentions nix, assign to `rekram1-node`. + +#### zen + +**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black". + +If the issue doesn't have "zen" or "opencode black" in it then don't add zen label + +#### core + +Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`. + +Examples: + +- LSP server behavior +- Harness behavior (agent + tools) +- Feature requests for server behavior +- Agent context construction +- API endpoints +- Provider integration issues +- New, broken, or poor-quality models + +#### acp + +If the issue mentions acp support, assign acp label. + +#### docs + +Add if the issue requests better documentation or docs updates. + +#### opentui + +TUI issues potentially caused by our underlying TUI library: + +- Keybindings not working +- Scroll speed issues (too fast/slow/laggy) +- Screen flickering +- Crashes with opentui in the log + +**Do not** add for general TUI bugs. + +When assigning to people here are the following rules: + +Desktop / Web: +Use for desktop-labeled issues only. + +- adamdotdevin +- iamdavidhill +- Brendonovich +- nexxeln + +Zen: +ONLY assign if the issue will have the "zen" label. + +- fwang +- MrMushrooooom + +TUI (`packages/opencode/src/cli/cmd/tui/...`): + +- thdxr for TUI UX/UI product decisions and interaction flow +- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks +- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues + +Core (`packages/opencode/...`, excluding TUI subtree): + +- thdxr for sqlite/snapshot/memory bugs and larger architectural core features +- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable) +- rekram1-node for harness issues, provider issues, and other bug-squashing + +For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable. + +Docs: + +- R44VC0RP + +Windows: + +- Hona (assign any issue that mentions Windows or is likely Windows-specific) + +Determinism rules: + +- If title + body does not contain "zen", do not add the "zen" label +- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix" +- If title + body mentions nix/nixos, assign to `rekram1-node` +- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner + +In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random. + +ACP: + +- rekram1-node (assign any acp issues to rekram1-node) diff --git a/Agent-backend/.opencode/command/CLAUDE.md b/Agent-backend/.opencode/command/CLAUDE.md new file mode 100644 index 000000000..9fa82295d --- /dev/null +++ b/Agent-backend/.opencode/command/CLAUDE.md @@ -0,0 +1,26 @@ +# command + +> **Path:** `Agent-backend/.opencode/command/` + +## Purpose + +Custom slash commands for the OpenCode project. Each markdown file defines a command that can be invoked via `/command-name` in the agent chat. Commands can specify a model, description, and whether they run as subtasks. The frontmatter configures behavior; the body is the prompt. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ---------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Demonstrates the command definition format for Orbit's `.orbit/command/` directory | +| Orbit CLI | `reference` | Same — the CLI loads commands from `.orbit/command/` using this format | + +## Files + +| File | Purpose | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `ai-deps.md` | AI dependency management command | +| `audit-plan.md` | Audit/planning command | +| `commit.md` | Git commit + push command — runs `git diff`, enforces prefix (`docs:`, `tui:`, `core:`, `ci:`, etc.), user-facing commit messages | +| `issues.md` | GitHub issues command | +| `learn.md` | Learning/context gathering command | +| `rmslop.md` | Remove AI-generated slop — strips unnecessary comments, defensive checks, `any` casts, emoji, style inconsistencies | +| `spellcheck.md` | Spellchecking command | diff --git a/Agent-backend/.opencode/command/ai-deps.md b/Agent-backend/.opencode/command/ai-deps.md new file mode 100644 index 000000000..83783d5b9 --- /dev/null +++ b/Agent-backend/.opencode/command/ai-deps.md @@ -0,0 +1,24 @@ +--- +description: "Bump AI sdk dependencies minor / patch versions only" +--- + +Please read @package.json and @packages/opencode/package.json. + +Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes). + +I want a report of every dependency and the version that can be upgraded to. +What would be even better is if you can give me brief summary of the changes for each dep and a link to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added. + +Consider using subagents for each dep to save your context window. + +Here is a short list of some deps (please be comprehensive tho): + +- "ai" +- "@ai-sdk/openai" +- "@ai-sdk/anthropic" +- "@openrouter/ai-sdk-provider" +- etc, etc + +DO NOT upgrade the dependencies yet, just make a list of all dependencies and their versions that can be upgraded to minor or patch versions only. + +Write up your findings to ai-sdk-updates.md diff --git a/Agent-backend/.opencode/command/audit-plan.md b/Agent-backend/.opencode/command/audit-plan.md new file mode 100644 index 000000000..061986c2f --- /dev/null +++ b/Agent-backend/.opencode/command/audit-plan.md @@ -0,0 +1,225 @@ +--- +description: Audit a refactor plan for correctness, architecture, performance, and production readiness +argument-hint: +allowed-tools: Read, Glob, Grep, Write, Bash(git diff:*), Bash(git log:*), Bash(git branch:*) +--- + +# Architectural Audit + +You are performing a comprehensive code review and architectural audit of a proposed refactor plan. + +**Plan document**: $1 + +## Validation + +If `$1` is empty, output: + +``` +Usage: /audit-plan +Example: /audit-plan docs/ICON-THEME-SYSTEM.md +``` + +Stop execution. + +## Context + +- Current branch: !`git branch --show-current` +- This is a **Tauri 2 + React 19** production application with strict TypeScript, Zustand + Immer state management, and a Rust backend. + +--- + +## Step 0: Understand the Plan + +Read the plan document at `$1` completely. Extract: + +1. **What system is being refactored** (e.g., icon themes, state management, routing) +2. **Goal of the refactor** (e.g., extensibility, performance, simplification) +3. **Current implementation files** mentioned or implied +4. **Proposed new files/patterns** +5. **Reference patterns** in the codebase (similar existing systems) + +If the plan document doesn't exist, output "Plan document not found at `$1`" and stop. + +--- + +## Step 1: Read Current Implementations + +Read every file the plan proposes to change or replace. Also read: + +- Direct imports and dependencies of those files +- At least one similar existing pattern for comparison (e.g., if adding a provider, read an existing provider) + +**Stop reading when you can answer:** + +- What does the current code do? +- What are its inputs, outputs, and side effects? +- What patterns does it follow? + +--- + +## Step 2: Audit Against Criteria + +Evaluate the plan against ALL of the following. **Skip sections that don't apply**, but be explicit about what you skipped and why. + +### 2.1 Correctness & Proven Patterns + +- Does the proposed abstraction correctly handle the core responsibility? +- Is the chosen state management pattern (Context, Zustand, hooks, etc.) consistent with the codebase? +- Are there any algorithmic complexity regressions (e.g., O(1) to O(n))? +- Any risk of breaking existing build/bundler patterns (Vite glob, tree-shaking, code splitting)? + +### 2.2 Architecture & Design + +- Is the proposed API surface the right abstraction level? Too broad? Too narrow? +- Should this logic live in a provider, a hook, a utility, or a service? +- Is centralizing logic better than keeping it co-located with components? +- How does this integrate with existing related systems? +- Does the abstraction boundary make sense for testing? + +### 2.3 Performance + +- Compare current vs proposed loading/rendering strategy +- Does the refactor change Vite's tree-shaking or code-splitting behavior? +- Will the new pattern cause unnecessary re-renders? +- Where should memoization happen (provider level, hook level, component level)? +- Any bundle size impact? + +### 2.4 Framework & Tooling Best Practices + +- Is the proposed pattern consistent with codebase conventions? +- Should we add memoization (React.memo, useMemo, useCallback) to prevent re-renders? +- Is the current loading strategy (eager/lazy) still the right choice post-refactor? +- Does it follow the project's ESLint, TypeScript, and import conventions? + +### 2.5 Production Readiness + +- Error handling: What happens when expected resources are missing? +- Fallback chain: Is the degradation path robust? +- Edge cases: Identify at least 3-5 concrete edge cases the plan doesn't address +- Type safety: Are all types strict (no `any`, no `@ts-ignore`)? +- What happens if required providers/contexts aren't mounted? +- What happens under concurrent access or race conditions? + +### 2.6 Future Extensibility + +- Does the architecture support likely future capabilities? +- Can we add external format/standard compatibility without major refactoring? +- Is there a clean path to add user-facing configuration? +- Should we define formal interfaces/schemas now for forward compatibility? + +### 2.7 Missing Considerations + +- Does the plan address all variants/modes (dark mode, responsive, etc.)? +- How do custom/external configurations handle mode variants? +- Should there be validation for external inputs (format checks, required fields)? +- Is the persistence strategy (localStorage, backend settings, config file) appropriate? + +### 2.8 Test Coverage + +- Are there existing tests that verify no regressions? +- Should we add unit tests for the new resolution/loading logic? +- Should we add visual regression tests for affected UI? +- How do we verify that all existing functionality still works at scale? + +--- + +## Step 3: Write Audit Report + +Write the audit to `reviews/audit-plan.md`: + +```markdown +# Plan Audit: [Plan Title] + +**Date**: [current date] +**Plan Document**: $1 +**Branch**: [branch name] + +## Plan Summary + +[2-3 sentences: What the refactor does, what it replaces, and the stated goal] + +## Files Reviewed + +| File | Role | Risk | +| -------------- | ---------------------- | ---- | +| `path/to/file` | Current implementation | High | +| `path/to/file` | Reference pattern | Low | + +_Risk: High (core logic, many dependents), Medium (feature code), Low (utilities, tests)_ + +## Verdict: [APPROVE / APPROVE WITH CHANGES / NEEDS REWORK] + +[1-2 sentence justification] + +## Critical Issues (Must Fix Before Implementation) + +| # | Section | Problem | Recommendation | +| --- | ------- | -------------- | -------------- | +| 1 | 2.1 | [What's wrong] | [How to fix] | + +## Recommended Improvements (Should Consider) + +| # | Section | Problem | Recommendation | +| --- | ------- | ---------------------- | -------------- | +| 1 | 2.3 | [What could be better] | [Suggestion] | + +## Nice-to-Haves (Optional Enhancements) + +| # | Section | Idea | Benefit | +| --- | ------- | ------------- | -------------- | +| 1 | 2.6 | [Enhancement] | [Why it helps] | + +## Edge Cases Not Addressed + +[Concrete scenarios the plan doesn't handle] + +- What happens if X? +- What happens when Y? + +## Code Suggestions + +[Specific code examples for critical issues and recommended improvements] + +## Verdict Details + +### Correctness: [PASS / CONCERNS] + +[Details] + +### Architecture: [PASS / CONCERNS] + +[Details] + +### Performance: [PASS / CONCERNS] + +[Details] + +### Production Readiness: [PASS / CONCERNS] + +[Details] + +### Extensibility: [PASS / CONCERNS] + +[Details] +``` + +--- + +## Step 4: Output + +After writing file: + +1. Print: `Audit written to reviews/audit-plan.md` +2. Print the **Verdict**, **Critical Issues**, and **Edge Cases** sections + +--- + +## Policies + +- **Be thorough.** This is production code. A missed issue can affect the entire UI or data flow. +- **Don't rubber-stamp.** If the plan is good, say so with evidence. If it has problems, say so directly. +- **Be concrete.** Every issue should reference specific files, lines, or code patterns. Vague concerns like "might have performance issues" are not helpful without specifics. +- **Respect existing patterns.** If the codebase already has a convention, the plan should follow it unless there's a compelling reason not to. +- **Code suggestions required.** For every critical issue, provide a concrete code example showing the fix. + +Begin by reading the plan document. diff --git a/Agent-backend/.opencode/command/commit.md b/Agent-backend/.opencode/command/commit.md new file mode 100644 index 000000000..e88932a24 --- /dev/null +++ b/Agent-backend/.opencode/command/commit.md @@ -0,0 +1,37 @@ +--- +description: git commit and push +model: opencode/kimi-k2.5 +subtask: true +--- + +commit and push + +make sure it includes a prefix like +docs: +tui: +core: +ci: +ignore: +wip: + +For anything in the packages/web use the docs: prefix. + +prefer to explain WHY something was done from an end user perspective instead of +WHAT was done. + +do not do generic messages like "improved agent experience" be very specific +about what user facing changes were made + +if there are conflicts DO NOT FIX THEM. notify me and I will fix them + +## GIT DIFF + +!`git diff` + +## GIT DIFF --cached + +!`git diff --cached` + +## GIT STATUS --short + +!`git status --short` diff --git a/Agent-backend/.opencode/command/issues.md b/Agent-backend/.opencode/command/issues.md new file mode 100644 index 000000000..75b596167 --- /dev/null +++ b/Agent-backend/.opencode/command/issues.md @@ -0,0 +1,23 @@ +--- +description: "find issue(s) on github" +model: opencode/claude-haiku-4-5 +--- + +Search through existing issues in anomalyco/opencode using the gh cli to find issues matching this query: + +$ARGUMENTS + +Consider: + +1. Similar titles or descriptions +2. Same error messages or symptoms +3. Related functionality or components +4. Similar feature requests + +Please list any matching issues with: + +- Issue number and title +- Brief explanation of why it matches the query +- Link to the issue + +If no clear matches are found, say so. diff --git a/Agent-backend/.opencode/command/learn.md b/Agent-backend/.opencode/command/learn.md new file mode 100644 index 000000000..fe4965a58 --- /dev/null +++ b/Agent-backend/.opencode/command/learn.md @@ -0,0 +1,42 @@ +--- +description: Extract non-obvious learnings from session to AGENTS.md files to build codebase understanding +--- + +Analyze this session and extract non-obvious learnings to add to AGENTS.md files. + +AGENTS.md files can exist at any directory level, not just the project root. When an agent reads a file, any AGENTS.md in parent directories are automatically loaded into the context of the tool read. Place learnings as close to the relevant code as possible: + +- Project-wide learnings → root AGENTS.md +- Package/module-specific → packages/foo/AGENTS.md +- Feature-specific → src/auth/AGENTS.md + +What counts as a learning (non-obvious discoveries only): + +- Hidden relationships between files or modules +- Execution paths that differ from how code appears +- Non-obvious configuration, env vars, or flags +- Debugging breakthroughs when error messages were misleading +- API/tool quirks and workarounds +- Build/test commands not in README +- Architectural decisions and constraints +- Files that must change together + +What NOT to include: + +- Obvious facts from documentation +- Standard language/framework behavior +- Things already in an AGENTS.md +- Verbose explanations +- Session-specific details + +Process: + +1. Review session for discoveries, errors that took multiple attempts, unexpected connections +2. Determine scope - what directory does each learning apply to? +3. Read existing AGENTS.md files at relevant levels +4. Create or update AGENTS.md at the appropriate level +5. Keep entries to 1-3 lines per insight + +After updating, summarize which AGENTS.md files were created/updated and how many learnings per file. + +$ARGUMENTS diff --git a/Agent-backend/.opencode/command/rmslop.md b/Agent-backend/.opencode/command/rmslop.md new file mode 100644 index 000000000..02c9fc084 --- /dev/null +++ b/Agent-backend/.opencode/command/rmslop.md @@ -0,0 +1,15 @@ +--- +description: Remove AI code slop +--- + +Check the diff against dev, and remove all AI generated slop introduced in this branch. + +This includes: + +- Extra comments that a human wouldn't add or is inconsistent with the rest of the file +- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths) +- Casts to any to get around type issues +- Any other style that is inconsistent with the file +- Unnecessary emoji usage + +Report at the end with only a 1-3 sentence summary of what you changed diff --git a/Agent-backend/.opencode/command/spellcheck.md b/Agent-backend/.opencode/command/spellcheck.md new file mode 100644 index 000000000..0abf23c4f --- /dev/null +++ b/Agent-backend/.opencode/command/spellcheck.md @@ -0,0 +1,5 @@ +--- +description: spellcheck all markdown file changes +--- + +Look at all the unstaged changes to markdown (.md, .mdx) files, pull out the lines that have changed, and check for spelling and grammar errors. diff --git a/Agent-backend/.opencode/env.d.ts b/Agent-backend/.opencode/env.d.ts new file mode 100644 index 000000000..f2b13a934 --- /dev/null +++ b/Agent-backend/.opencode/env.d.ts @@ -0,0 +1,4 @@ +declare module "*.txt" { + const content: string + export default content +} diff --git a/Agent-backend/.opencode/glossary/CLAUDE.md b/Agent-backend/.opencode/glossary/CLAUDE.md new file mode 100644 index 000000000..fdeab14f7 --- /dev/null +++ b/Agent-backend/.opencode/glossary/CLAUDE.md @@ -0,0 +1,36 @@ +# glossary + +> **Path:** `Agent-backend/.opencode/glossary/` + +## Purpose + +Locale-specific translation glossaries that supplement the translator agent. Each markdown file captures community-learned phrasing preferences, do-not-translate terms, and consistency guidance for a specific language. Used by the `translator` agent when translating documentation across 17 locales. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ------------------------------------------------------------- | +| Orbit Desktop (SDK) | `not used` | Orbit does not currently have i18n | +| Orbit CLI | `reference` | Glossary format could be adopted if the CLI adds i18n support | + +## Files + +| File | Purpose | +| ----------- | -------------------------------------------------------------------- | +| `README.md` | Glossary contribution guide — file naming, template, structure rules | +| `ar.md` | Arabic glossary | +| `br.md` | Brazilian Portuguese glossary | +| `bs.md` | Bosnian glossary | +| `da.md` | Danish glossary | +| `de.md` | German glossary | +| `es.md` | Spanish glossary | +| `fr.md` | French glossary | +| `ja.md` | Japanese glossary | +| `ko.md` | Korean glossary | +| `no.md` | Norwegian glossary | +| `pl.md` | Polish glossary | +| `ru.md` | Russian glossary | +| `th.md` | Thai glossary | +| `tr.md` | Turkish glossary | +| `zh-cn.md` | Simplified Chinese glossary | +| `zh-tw.md` | Traditional Chinese glossary | diff --git a/Agent-backend/.opencode/glossary/README.md b/Agent-backend/.opencode/glossary/README.md new file mode 100644 index 000000000..983900381 --- /dev/null +++ b/Agent-backend/.opencode/glossary/README.md @@ -0,0 +1,63 @@ +# Locale Glossaries + +Use this folder for locale-specific translation guidance that supplements `.opencode/agent/translator.md`. + +The global glossary in `translator.md` remains the source of truth for shared do-not-translate terms (commands, code, paths, product names, etc.). These locale files capture community learnings about phrasing and terminology preferences. + +## File Naming + +- One file per locale +- Use lowercase locale slugs that match docs locales when possible (for example, `zh-cn.md`, `zh-tw.md`) +- If only language-level guidance exists, use the language code (for example, `fr.md`) +- Some repo locale slugs may be aliases/non-BCP47 for consistency (for example, `br` for Brazilian Portuguese / `pt-BR`) + +## What To Put In A Locale File + +- **Sources**: PRs/issues/discussions that motivated the guidance +- **Do Not Translate (Locale Additions)**: locale-specific terms or casing decisions +- **Preferred Terms**: recurring UI/docs words with preferred translations +- **Guidance**: tone, style, and consistency notes +- **Avoid** (optional): common literal translations or wording we should avoid +- If the repo uses a locale alias slug, document the alias in **Guidance** (for example, prose may mention `pt-BR` while config/examples use `br`) + +Prefer guidance that is: + +- Repeated across multiple docs/screens +- Easy to apply consistently +- Backed by a community contribution or review discussion + +## Template + +```md +# Glossary + +## Sources + +- PR #12345: https://github.com/anomalyco/opencode/pull/12345 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing) + +## Preferred Terms + +| English | Preferred | Notes | +| ------- | --------- | --------- | +| prompt | ... | preferred | +| session | ... | preferred | + +## Guidance + +- Prefer natural phrasing over literal translation + +## Avoid + +- Avoid ... when ... +``` + +## Contribution Notes + +- Mark entries as preferred when they may evolve +- Keep examples short +- Add or update the `Sources` section whenever you add a new rule +- Prefer PR-backed guidance over invented term mappings; start with general guidance if no term-level corrections exist yet diff --git a/Agent-backend/.opencode/glossary/ar.md b/Agent-backend/.opencode/glossary/ar.md new file mode 100644 index 000000000..37355522a --- /dev/null +++ b/Agent-backend/.opencode/glossary/ar.md @@ -0,0 +1,28 @@ +# ar Glossary + +## Sources + +- PR #9947: https://github.com/anomalyco/opencode/pull/9947 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections. + +## Guidance + +- Prefer natural Arabic phrasing over literal translation +- Keep tone clear and direct in UI labels and docs prose +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths +- For RTL text, treat code, commands, and paths as LTR artifacts and keep their character order unchanged + +## Avoid + +- Avoid translating product and protocol names that are fixed identifiers +- Avoid mixing multiple Arabic terms for the same recurring UI action once a preferred term is established diff --git a/Agent-backend/.opencode/glossary/br.md b/Agent-backend/.opencode/glossary/br.md new file mode 100644 index 000000000..fd3e7251c --- /dev/null +++ b/Agent-backend/.opencode/glossary/br.md @@ -0,0 +1,34 @@ +# br Glossary + +## Sources + +- PR #10086: https://github.com/anomalyco/opencode/pull/10086 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Locale code `br` in repo config, code, and paths (repo alias for Brazilian Portuguese) + +## Preferred Terms + +These are PR-backed locale naming preferences and may evolve. + +| English / Context | Preferred | Notes | +| ---------------------------------------- | ------------------------------ | ------------------------------------------------------------- | +| Brazilian Portuguese (prose locale name) | `pt-BR` | Use standard locale naming in prose when helpful | +| Repo locale slug (code/config) | `br` | PR #10086 uses `br` for consistency/simplicity | +| Browser locale detection | `pt`, `pt-br`, `pt-BR` -> `br` | Preserve this mapping in docs/examples about locale detection | + +## Guidance + +- This file covers Brazilian Portuguese (`pt-BR`), but the repo locale code is `br` +- Use natural Brazilian Portuguese phrasing over literal translation +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths +- Keep repo locale identifiers as implemented in code/config (`br`) even when prose mentions `pt-BR` + +## Avoid + +- Avoid changing repo locale code references from `br` to `pt-br` in code snippets, paths, or config examples +- Avoid mixing Portuguese variants when a Brazilian Portuguese form is established diff --git a/Agent-backend/.opencode/glossary/bs.md b/Agent-backend/.opencode/glossary/bs.md new file mode 100644 index 000000000..aa3bd96f6 --- /dev/null +++ b/Agent-backend/.opencode/glossary/bs.md @@ -0,0 +1,33 @@ +# bs Glossary + +## Sources + +- PR #12283: https://github.com/anomalyco/opencode/pull/12283 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +These are PR-backed locale naming preferences and may evolve. + +| English / Context | Preferred | Notes | +| ---------------------------------- | ---------- | ------------------------------------------------- | +| Bosnian language label (UI) | `Bosanski` | PR #12283 tested switching language to `Bosanski` | +| Repo locale slug (code/config) | `bs` | Preserve in code, config, paths, and examples | +| Browser locale detection (Bosnian) | `bs` | PR #12283 added `bs` locale auto-detection | + +## Guidance + +- Use natural Bosnian phrasing over literal translation +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths +- Keep repo locale references as `bs` in code/config, and use `Bosanski` for the user-facing language name when applicable + +## Avoid + +- Avoid changing repo locale references from `bs` to another slug in code snippets or config examples +- Avoid translating product and protocol names that are fixed identifiers diff --git a/Agent-backend/.opencode/glossary/da.md b/Agent-backend/.opencode/glossary/da.md new file mode 100644 index 000000000..e63222170 --- /dev/null +++ b/Agent-backend/.opencode/glossary/da.md @@ -0,0 +1,27 @@ +# da Glossary + +## Sources + +- PR #9821: https://github.com/anomalyco/opencode/pull/9821 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections. + +## Guidance + +- Prefer natural Danish phrasing over literal translation +- Keep tone clear and direct in UI labels and docs prose +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths + +## Avoid + +- Avoid translating product and protocol names that are fixed identifiers +- Avoid mixing multiple Danish terms for the same recurring UI action once a preferred term is established diff --git a/Agent-backend/.opencode/glossary/de.md b/Agent-backend/.opencode/glossary/de.md new file mode 100644 index 000000000..0d2c49fac --- /dev/null +++ b/Agent-backend/.opencode/glossary/de.md @@ -0,0 +1,27 @@ +# de Glossary + +## Sources + +- PR #9817: https://github.com/anomalyco/opencode/pull/9817 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections. + +## Guidance + +- Prefer natural German phrasing over literal translation +- Keep tone clear and direct in UI labels and docs prose +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths + +## Avoid + +- Avoid translating product and protocol names that are fixed identifiers +- Avoid mixing multiple German terms for the same recurring UI action once a preferred term is established diff --git a/Agent-backend/.opencode/glossary/es.md b/Agent-backend/.opencode/glossary/es.md new file mode 100644 index 000000000..dc9b977ec --- /dev/null +++ b/Agent-backend/.opencode/glossary/es.md @@ -0,0 +1,27 @@ +# es Glossary + +## Sources + +- PR #9817: https://github.com/anomalyco/opencode/pull/9817 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections. + +## Guidance + +- Prefer natural Spanish phrasing over literal translation +- Keep tone clear and direct in UI labels and docs prose +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths + +## Avoid + +- Avoid translating product and protocol names that are fixed identifiers +- Avoid mixing multiple Spanish terms for the same recurring UI action once a preferred term is established diff --git a/Agent-backend/.opencode/glossary/fr.md b/Agent-backend/.opencode/glossary/fr.md new file mode 100644 index 000000000..074c4de11 --- /dev/null +++ b/Agent-backend/.opencode/glossary/fr.md @@ -0,0 +1,27 @@ +# fr Glossary + +## Sources + +- PR #9821: https://github.com/anomalyco/opencode/pull/9821 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections. + +## Guidance + +- Prefer natural French phrasing over literal translation +- Keep tone clear and direct in UI labels and docs prose +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths + +## Avoid + +- Avoid translating product and protocol names that are fixed identifiers +- Avoid mixing multiple French terms for the same recurring UI action once a preferred term is established diff --git a/Agent-backend/.opencode/glossary/ja.md b/Agent-backend/.opencode/glossary/ja.md new file mode 100644 index 000000000..f0159ca96 --- /dev/null +++ b/Agent-backend/.opencode/glossary/ja.md @@ -0,0 +1,33 @@ +# ja Glossary + +## Sources + +- PR #9821: https://github.com/anomalyco/opencode/pull/9821 +- PR #13160: https://github.com/anomalyco/opencode/pull/13160 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +These are PR-backed wording preferences and may evolve. + +| English / Context | Preferred | Notes | +| --------------------------- | ----------------------- | ------------------------------------- | +| WSL integration (UI label) | `WSL連携` | PR #13160 prefers this over `WSL統合` | +| WSL integration description | `WindowsのWSL環境で...` | PR #13160 improved phrasing naturally | + +## Guidance + +- Prefer natural Japanese phrasing over literal translation +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths +- In WSL integration text, follow PR #13160 wording direction for more natural Japanese phrasing + +## Avoid + +- Avoid `WSL統合` in the WSL integration UI context where `WSL連携` is the reviewed wording +- Avoid translating product and protocol names that are fixed identifiers diff --git a/Agent-backend/.opencode/glossary/ko.md b/Agent-backend/.opencode/glossary/ko.md new file mode 100644 index 000000000..71385c8a1 --- /dev/null +++ b/Agent-backend/.opencode/glossary/ko.md @@ -0,0 +1,27 @@ +# ko Glossary + +## Sources + +- PR #9817: https://github.com/anomalyco/opencode/pull/9817 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections. + +## Guidance + +- Prefer natural Korean phrasing over literal translation +- Keep tone clear and direct in UI labels and docs prose +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths + +## Avoid + +- Avoid translating product and protocol names that are fixed identifiers +- Avoid mixing multiple Korean terms for the same recurring UI action once a preferred term is established diff --git a/Agent-backend/.opencode/glossary/no.md b/Agent-backend/.opencode/glossary/no.md new file mode 100644 index 000000000..d7159dca4 --- /dev/null +++ b/Agent-backend/.opencode/glossary/no.md @@ -0,0 +1,38 @@ +# no Glossary + +## Sources + +- PR #10018: https://github.com/anomalyco/opencode/pull/10018 +- PR #12935: https://github.com/anomalyco/opencode/pull/12935 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Sound names (PR #10018 notes these were intentionally left untranslated) + +## Preferred Terms + +These are PR-backed corrections and may evolve. + +| English / Context | Preferred | Notes | +| ----------------------------------- | ------------ | ----------------------------- | +| Save (data persistence action) | `Lagre` | Prefer over `Spare` | +| Disabled (feature/state) | `deaktivert` | Prefer over `funksjonshemmet` | +| API keys | `API Nøkler` | Prefer over `API Taster` | +| Cost (noun) | `Kostnad` | Prefer over verb form `Koste` | +| Show/View (imperative button label) | `Vis` | Prefer over `Utsikt` | + +## Guidance + +- Prefer natural Norwegian Bokmal (Bokmål) wording over literal translation +- Keep tone clear and practical in UI labels +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths +- Keep recurring UI terms consistent once a preferred term is chosen + +## Avoid + +- Avoid `Spare` for save actions in persistence contexts +- Avoid `funksjonshemmet` for disabled feature states +- Avoid `API Taster`, `Koste`, and `Utsikt` in the corrected contexts above diff --git a/Agent-backend/.opencode/glossary/pl.md b/Agent-backend/.opencode/glossary/pl.md new file mode 100644 index 000000000..e9bad7a51 --- /dev/null +++ b/Agent-backend/.opencode/glossary/pl.md @@ -0,0 +1,27 @@ +# pl Glossary + +## Sources + +- PR #9884: https://github.com/anomalyco/opencode/pull/9884 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections. + +## Guidance + +- Prefer natural Polish phrasing over literal translation +- Keep tone clear and direct in UI labels and docs prose +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths + +## Avoid + +- Avoid translating product and protocol names that are fixed identifiers +- Avoid mixing multiple Polish terms for the same recurring UI action once a preferred term is established diff --git a/Agent-backend/.opencode/glossary/ru.md b/Agent-backend/.opencode/glossary/ru.md new file mode 100644 index 000000000..6fee0f94c --- /dev/null +++ b/Agent-backend/.opencode/glossary/ru.md @@ -0,0 +1,27 @@ +# ru Glossary + +## Sources + +- PR #9882: https://github.com/anomalyco/opencode/pull/9882 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +No PR-backed term mappings yet. Add entries here when review PRs introduce repeated wording corrections. + +## Guidance + +- Prefer natural Russian phrasing over literal translation +- Keep tone clear and direct in UI labels and docs prose +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths + +## Avoid + +- Avoid translating product and protocol names that are fixed identifiers +- Avoid mixing multiple Russian terms for the same recurring UI action once a preferred term is established diff --git a/Agent-backend/.opencode/glossary/th.md b/Agent-backend/.opencode/glossary/th.md new file mode 100644 index 000000000..7b5a31d16 --- /dev/null +++ b/Agent-backend/.opencode/glossary/th.md @@ -0,0 +1,34 @@ +# th Glossary + +## Sources + +- PR #10809: https://github.com/anomalyco/opencode/pull/10809 +- PR #11496: https://github.com/anomalyco/opencode/pull/11496 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only in commands, package names, paths, or code) +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +These are PR-backed preferences and may evolve. + +| English / Context | Preferred | Notes | +| ------------------------------------- | --------------------- | -------------------------------------------------------------------------------- | +| Thai language label in language lists | `ไทย` | PR #10809 standardized this across locales | +| Language names in language pickers | Native names (static) | PR #11496: keep names like `English`, `Deutsch`, `ไทย` consistent across locales | + +## Guidance + +- Prefer natural Thai phrasing over literal translation +- Keep tone short and clear for buttons and labels +- Preserve technical artifacts exactly: commands, flags, code, URLs, model IDs, and file paths +- Keep language names static/native in language pickers instead of translating them per current locale (PR #11496) + +## Avoid + +- Avoid translating language names differently per current locale in language lists +- Avoid changing `ไทย` to another display form for the Thai language option unless the product standard changes diff --git a/Agent-backend/.opencode/glossary/tr.md b/Agent-backend/.opencode/glossary/tr.md new file mode 100644 index 000000000..72b1cdfb4 --- /dev/null +++ b/Agent-backend/.opencode/glossary/tr.md @@ -0,0 +1,38 @@ +# tr Glossary + +## Sources + +- PR #15835: https://github.com/anomalyco/opencode/pull/15835 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose, docs, and UI copy) +- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers +- `` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose +- Commands, flags, file paths, and code literals (keep exactly as written) + +## Preferred Terms + +These are PR-backed wording preferences and may evolve. + +| English / Context | Preferred | Notes | +| ------------------------- | --------------------------------------- | ------------------------------------------------------------- | +| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` | +| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` | +| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase | +| `` key label | `Tab` | Use `Tab` in prose; keep `` in literal UI or code blocks | +| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful | + +## Guidance + +- Prefer natural Turkish phrasing over literal translation +- Merge broken sentence fragments into one clear sentence when the source is a single thought +- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers +- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability + +## Avoid + +- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits +- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead +- Avoid `Sekme` for the translated key label in prose when referring to `` +- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals diff --git a/Agent-backend/.opencode/glossary/zh-cn.md b/Agent-backend/.opencode/glossary/zh-cn.md new file mode 100644 index 000000000..054e94b7e --- /dev/null +++ b/Agent-backend/.opencode/glossary/zh-cn.md @@ -0,0 +1,42 @@ +# zh-cn Glossary + +## Sources + +- PR #13942: https://github.com/anomalyco/opencode/pull/13942 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only when it is part of commands, package names, paths, or code) +- `OpenCode Zen` +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- `Model Context Protocol` (prefer the English expansion when introducing `MCP`) + +## Preferred Terms + +These are preferred terms for docs/UI prose and may evolve. + +| English | Preferred | Notes | +| ----------------------- | --------- | ------------------------------------------- | +| prompt | 提示词 | Keep `--prompt` unchanged in flags/code | +| session | 会话 | | +| provider | 提供商 | | +| share link / shared URL | 分享链接 | Prefer `分享` for user-facing share actions | +| headless (server) | 无界面 | Docs wording | +| authentication | 认证 | Prefer in auth/OAuth contexts | +| cache | 缓存 | | +| keybind / shortcut | 快捷键 | User-facing docs wording | +| workflow | 工作流 | e.g. GitHub Actions workflow | + +## Guidance + +- Prefer natural, concise phrasing over literal translation +- Keep the tone direct and friendly (PR #13942 consistently moved wording in this direction) +- Preserve technical artifacts exactly: commands, flags, code, inline code, URLs, file paths, model IDs +- Keep enum-like values in English when they are literals (for example, `default`, `json`) +- Prefer consistent terminology across pages once a term is chosen (`会话`, `提供商`, `提示词`, etc.) + +## Avoid + +- Avoid `opencode` in prose when referring to the product name; use `OpenCode` +- Avoid mixing alternative terms for the same concept across docs when a preferred term is already established diff --git a/Agent-backend/.opencode/glossary/zh-tw.md b/Agent-backend/.opencode/glossary/zh-tw.md new file mode 100644 index 000000000..283660e12 --- /dev/null +++ b/Agent-backend/.opencode/glossary/zh-tw.md @@ -0,0 +1,42 @@ +# zh-tw Glossary + +## Sources + +- PR #13942: https://github.com/anomalyco/opencode/pull/13942 + +## Do Not Translate (Locale Additions) + +- `OpenCode` (preserve casing in prose; keep `opencode` only when it is part of commands, package names, paths, or code) +- `OpenCode Zen` +- `OpenCode CLI` +- `CLI`, `TUI`, `MCP`, `OAuth` +- `Model Context Protocol` (prefer the English expansion when introducing `MCP`) + +## Preferred Terms + +These are preferred terms for docs/UI prose and may evolve. + +| English | Preferred | Notes | +| ----------------------- | --------- | ------------------------------------------- | +| prompt | 提示詞 | Keep `--prompt` unchanged in flags/code | +| session | 工作階段 | | +| provider | 供應商 | | +| share link / shared URL | 分享連結 | Prefer `分享` for user-facing share actions | +| headless (server) | 無介面 | Docs wording | +| authentication | 認證 | Prefer in auth/OAuth contexts | +| cache | 快取 | | +| keybind / shortcut | 快捷鍵 | User-facing docs wording | +| workflow | 工作流程 | e.g. GitHub Actions workflow | + +## Guidance + +- Prefer natural, concise phrasing over literal translation +- Keep the tone direct and friendly (PR #13942 consistently moved wording in this direction) +- Preserve technical artifacts exactly: commands, flags, code, inline code, URLs, file paths, model IDs +- Keep enum-like values in English when they are literals (for example, `default`, `json`) +- Prefer consistent terminology across pages once a term is chosen (`工作階段`, `供應商`, `提示詞`, etc.) + +## Avoid + +- Avoid `opencode` in prose when referring to the product name; use `OpenCode` +- Avoid mixing alternative terms for the same concept across docs when a preferred term is already established diff --git a/Agent-backend/.opencode/opencode.jsonc b/Agent-backend/.opencode/opencode.jsonc new file mode 100644 index 000000000..8380f7f71 --- /dev/null +++ b/Agent-backend/.opencode/opencode.jsonc @@ -0,0 +1,18 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "opencode": { + "options": {}, + }, + }, + "permission": { + "edit": { + "packages/opencode/migration/*": "deny", + }, + }, + "mcp": {}, + "tools": { + "github-triage": false, + "github-pr-search": false, + }, +} diff --git a/Agent-backend/.opencode/themes/CLAUDE.md b/Agent-backend/.opencode/themes/CLAUDE.md new file mode 100644 index 000000000..0df7cf870 --- /dev/null +++ b/Agent-backend/.opencode/themes/CLAUDE.md @@ -0,0 +1,20 @@ +# themes + +> **Path:** `Agent-backend/.opencode/themes/` + +## Purpose + +Custom theme definitions for the OpenCode TUI. Theme files are JSON with a `$schema` reference to `opencode.ai/theme.json`. They define color palettes via a `defs` block (reusable color variables) and a `theme` block mapping semantic tokens (primary, text, background, diff, markdown, syntax) to light/dark variants. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Theme schema format could inform Orbit's theming system | +| Orbit CLI | `reference` | The CLI loads custom themes from `.orbit/themes/` using this format | + +## Files + +| File | Purpose | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `mytheme.json` | Example Nord-based theme — 16 Nord palette colors mapped to ~50 semantic tokens covering UI, diff, markdown, and syntax highlighting | diff --git a/Agent-backend/.opencode/themes/mytheme.json b/Agent-backend/.opencode/themes/mytheme.json new file mode 100644 index 000000000..e444de807 --- /dev/null +++ b/Agent-backend/.opencode/themes/mytheme.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nord0": "#2E3440", + "nord1": "#3B4252", + "nord2": "#434C5E", + "nord3": "#4C566A", + "nord4": "#D8DEE9", + "nord5": "#E5E9F0", + "nord6": "#ECEFF4", + "nord7": "#8FBCBB", + "nord8": "#88C0D0", + "nord9": "#81A1C1", + "nord10": "#5E81AC", + "nord11": "#BF616A", + "nord12": "#D08770", + "nord13": "#EBCB8B", + "nord14": "#A3BE8C", + "nord15": "#B48EAD" + }, + "theme": { + "primary": { + "dark": "nord8", + "light": "nord10" + }, + "secondary": { + "dark": "nord9", + "light": "nord9" + }, + "accent": { + "dark": "nord7", + "light": "nord7" + }, + "error": { + "dark": "nord11", + "light": "nord11" + }, + "warning": { + "dark": "nord12", + "light": "nord12" + }, + "success": { + "dark": "nord14", + "light": "nord14" + }, + "info": { + "dark": "nord8", + "light": "nord10" + }, + "text": { + "dark": "nord4", + "light": "nord0" + }, + "textMuted": { + "dark": "nord3", + "light": "nord1" + }, + "background": { + "dark": "nord0", + "light": "nord6" + }, + "backgroundPanel": { + "dark": "nord1", + "light": "nord5" + }, + "backgroundElement": { + "dark": "nord1", + "light": "nord4" + }, + "border": { + "dark": "nord2", + "light": "nord3" + }, + "borderActive": { + "dark": "nord3", + "light": "nord2" + }, + "borderSubtle": { + "dark": "nord2", + "light": "nord3" + }, + "diffAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffContext": { + "dark": "nord3", + "light": "nord3" + }, + "diffHunkHeader": { + "dark": "nord3", + "light": "nord3" + }, + "diffHighlightAdded": { + "dark": "nord14", + "light": "nord14" + }, + "diffHighlightRemoved": { + "dark": "nord11", + "light": "nord11" + }, + "diffAddedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffContextBg": { + "dark": "nord1", + "light": "nord5" + }, + "diffLineNumber": { + "dark": "nord2", + "light": "nord4" + }, + "diffAddedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "diffRemovedLineNumberBg": { + "dark": "#3B4252", + "light": "#E5E9F0" + }, + "markdownText": { + "dark": "nord4", + "light": "nord0" + }, + "markdownHeading": { + "dark": "nord8", + "light": "nord10" + }, + "markdownLink": { + "dark": "nord9", + "light": "nord9" + }, + "markdownLinkText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCode": { + "dark": "nord14", + "light": "nord14" + }, + "markdownBlockQuote": { + "dark": "nord3", + "light": "nord3" + }, + "markdownEmph": { + "dark": "nord12", + "light": "nord12" + }, + "markdownStrong": { + "dark": "nord13", + "light": "nord13" + }, + "markdownHorizontalRule": { + "dark": "nord3", + "light": "nord3" + }, + "markdownListItem": { + "dark": "nord8", + "light": "nord10" + }, + "markdownListEnumeration": { + "dark": "nord7", + "light": "nord7" + }, + "markdownImage": { + "dark": "nord9", + "light": "nord9" + }, + "markdownImageText": { + "dark": "nord7", + "light": "nord7" + }, + "markdownCodeBlock": { + "dark": "nord4", + "light": "nord0" + }, + "syntaxComment": { + "dark": "nord3", + "light": "nord3" + }, + "syntaxKeyword": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxFunction": { + "dark": "nord8", + "light": "nord8" + }, + "syntaxVariable": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxString": { + "dark": "nord14", + "light": "nord14" + }, + "syntaxNumber": { + "dark": "nord15", + "light": "nord15" + }, + "syntaxType": { + "dark": "nord7", + "light": "nord7" + }, + "syntaxOperator": { + "dark": "nord9", + "light": "nord9" + }, + "syntaxPunctuation": { + "dark": "nord4", + "light": "nord0" + } + } +} diff --git a/Agent-backend/.opencode/tool/CLAUDE.md b/Agent-backend/.opencode/tool/CLAUDE.md new file mode 100644 index 000000000..c0b680b35 --- /dev/null +++ b/Agent-backend/.opencode/tool/CLAUDE.md @@ -0,0 +1,23 @@ +# tool + +> **Path:** `Agent-backend/.opencode/tool/` + +## Purpose + +Custom tool definitions for the OpenCode project. Each tool is a paired `.ts` (implementation) + `.txt` (description prompt) file. Tools use the `@opencode-ai/plugin` SDK's `tool()` function to define typed arguments, descriptions, and async execute functions. These are project-specific tools loaded at runtime alongside built-in tools. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Demonstrates the custom tool format — same `.ts` + `.txt` pattern as built-in tools in `packages/opencode/src/tool/` | +| Orbit CLI | `reference` | The CLI loads custom tools from `.orbit/tool/` using this format | + +## Files + +| File | Purpose | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `github-pr-search.ts` | Tool implementation — searches GitHub PRs in `anomalyco/opencode` via GitHub Search API. Uses `@opencode-ai/plugin` `tool()` with typed args (`query`, `limit`, `offset`) | +| `github-pr-search.txt` | Tool description — tells the agent when and how to use the PR search tool | +| `github-triage.ts` | Tool implementation — assigns labels and owners to GitHub issues based on triage policy | +| `github-triage.txt` | Tool description — tells the agent to choose labels and assignees using current triage policy | diff --git a/Agent-backend/.opencode/tool/github-pr-search.ts b/Agent-backend/.opencode/tool/github-pr-search.ts new file mode 100644 index 000000000..587fdfaaf --- /dev/null +++ b/Agent-backend/.opencode/tool/github-pr-search.ts @@ -0,0 +1,57 @@ +/// +import { tool } from "@opencode-ai/plugin" +import DESCRIPTION from "./github-pr-search.txt" + +async function githubFetch(endpoint: string, options: RequestInit = {}) { + const response = await fetch(`https://api.github.com${endpoint}`, { + ...options, + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + ...options.headers, + }, + }) + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + +interface PR { + title: string + html_url: string +} + +export default tool({ + description: DESCRIPTION, + args: { + query: tool.schema.string().describe("Search query for PR titles and descriptions"), + limit: tool.schema.number().describe("Maximum number of results to return").default(10), + offset: tool.schema.number().describe("Number of results to skip for pagination").default(0), + }, + async execute(args) { + const owner = "anomalyco" + const repo = "opencode" + + const page = Math.floor(args.offset / args.limit) + 1 + const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:pr state:open`) + const result = await githubFetch( + `/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`, + ) + + if (result.total_count === 0) { + return `No PRs found matching "${args.query}"` + } + + const prs = result.items as PR[] + + if (prs.length === 0) { + return `No other PRs found matching "${args.query}"` + } + + const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n") + + return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}` + }, +}) diff --git a/Agent-backend/.opencode/tool/github-pr-search.txt b/Agent-backend/.opencode/tool/github-pr-search.txt new file mode 100644 index 000000000..1b658e71c --- /dev/null +++ b/Agent-backend/.opencode/tool/github-pr-search.txt @@ -0,0 +1,10 @@ +Use this tool to search GitHub pull requests by title and description. + +This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including: +- PR number and title +- Author +- State (open/closed/merged) +- Labels +- Description snippet + +Use the query parameter to search for keywords that might appear in PR titles or descriptions. diff --git a/Agent-backend/.opencode/tool/github-triage.ts b/Agent-backend/.opencode/tool/github-triage.ts new file mode 100644 index 000000000..8ad0212ad --- /dev/null +++ b/Agent-backend/.opencode/tool/github-triage.ts @@ -0,0 +1,119 @@ +/// +import { tool } from "@opencode-ai/plugin" +import DESCRIPTION from "./github-triage.txt" + +const TEAM = { + desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], + zen: ["fwang", "MrMushrooooom"], + tui: [ + "thdxr", + "kommander", + // "rekram1-node" (on vacation) + ], + core: [ + "thdxr", + // "rekram1-node", (on vacation) + "jlongster", + ], + docs: ["R44VC0RP"], + windows: ["Hona"], +} as const + +const ASSIGNEES = [...new Set(Object.values(TEAM).flat())] + +function pick(items: readonly T[]) { + return items[Math.floor(Math.random() * items.length)]! +} + +function getIssueNumber(): number { + const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10) + if (!issue) throw new Error("ISSUE_NUMBER env var not set") + return issue +} + +async function githubFetch(endpoint: string, options: RequestInit = {}) { + const response = await fetch(`https://api.github.com${endpoint}`, { + ...options, + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + ...options.headers, + }, + }) + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + +export default tool({ + description: DESCRIPTION, + args: { + assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"), + labels: tool.schema + .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) + .describe("The labels(s) to add to the issue") + .default([]), + }, + async execute(args) { + const issue = getIssueNumber() + const owner = "anomalyco" + const repo = "opencode" + + const results: string[] = [] + let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))] + const web = labels.includes("web") + const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase() + const zen = /\bzen\b/.test(text) || text.includes("opencode black") + const nix = /\bnix(os)?\b/.test(text) + + if (labels.includes("nix") && !nix) { + labels = labels.filter((x) => x !== "nix") + results.push("Dropped label: nix (issue does not mention nix)") + } + + // const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee + const assignee = web ? pick(TEAM.desktop) : args.assignee + + if (labels.includes("zen") && !zen) { + throw new Error("Only add the zen label when issue title/body contains 'zen'") + } + + if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) { + throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln") + } + + if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) { + throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom") + } + + if (assignee === "Hona" && !labels.includes("windows")) { + throw new Error("Only windows issues should be assigned to Hona") + } + + if (assignee === "R44VC0RP" && !labels.includes("docs")) { + throw new Error("Only docs issues should be assigned to R44VC0RP") + } + + if (assignee === "kommander" && !labels.includes("opentui")) { + throw new Error("Only opentui issues should be assigned to kommander") + } + + await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { + method: "POST", + body: JSON.stringify({ assignees: [assignee] }), + }) + results.push(`Assigned @${assignee} to issue #${issue}`) + + if (labels.length > 0) { + await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { + method: "POST", + body: JSON.stringify({ labels }), + }) + results.push(`Added labels: ${labels.join(", ")}`) + } + + return results.join("\n") + }, +}) diff --git a/Agent-backend/.opencode/tool/github-triage.txt b/Agent-backend/.opencode/tool/github-triage.txt new file mode 100644 index 000000000..1a2d69bdb --- /dev/null +++ b/Agent-backend/.opencode/tool/github-triage.txt @@ -0,0 +1,8 @@ +Use this tool to assign and/or label a GitHub issue. + +Choose labels and assignee using the current triage policy and ownership rules. +Pick the most fitting labels for the issue and assign one owner. + +If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random. + +(Note: rekram1-node is on vacation, do not assign issues to him.) diff --git a/Agent-backend/.prettierignore b/Agent-backend/.prettierignore new file mode 100644 index 000000000..a2a277659 --- /dev/null +++ b/Agent-backend/.prettierignore @@ -0,0 +1,2 @@ +sst-env.d.ts +packages/desktop/src/bindings.ts diff --git a/Agent-backend/AGENTS.md b/Agent-backend/AGENTS.md new file mode 100644 index 000000000..2158d73af --- /dev/null +++ b/Agent-backend/AGENTS.md @@ -0,0 +1,124 @@ +- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. +- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. +- The default branch in this repo is `dev`. +- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. +- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. + +## Style Guide + +### General Principles + +- Keep things in one function unless composable or reusable +- Avoid `try`/`catch` where possible +- Avoid using the `any` type +- Prefer single word variable names where possible +- Use Bun APIs when possible, like `Bun.file()` +- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity +- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream + +### Naming + +Prefer single word names for variables and functions. Only use multiple words if necessary. + +### Naming Enforcement (Read This) + +THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE. + +- Use single word names by default for new locals, params, and helper functions. +- Multi-word names are allowed only when a single word would be unclear or ambiguous. +- Do not introduce new camelCase compounds when a short single-word alternative is clear. +- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible. +- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`. +- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`. + +```ts +// Good +const foo = 1 +function journal(dir: string) {} + +// Bad +const fooBar = 1 +function prepareJournal(dir: string) {} +``` + +Reduce total variable count by inlining when a value is only used once. + +```ts +// Good +const journal = await Bun.file(path.join(dir, "journal.json")).json() + +// Bad +const journalPath = path.join(dir, "journal.json") +const journal = await Bun.file(journalPath).json() +``` + +### Destructuring + +Avoid unnecessary destructuring. Use dot notation to preserve context. + +```ts +// Good +obj.a +obj.b + +// Bad +const { a, b } = obj +``` + +### Variables + +Prefer `const` over `let`. Use ternaries or early returns instead of reassignment. + +```ts +// Good +const foo = condition ? 1 : 2 + +// Bad +let foo +if (condition) foo = 1 +else foo = 2 +``` + +### Control Flow + +Avoid `else` statements. Prefer early returns. + +```ts +// Good +function foo() { + if (condition) return 1 + return 2 +} + +// Bad +function foo() { + if (condition) return 1 + else return 2 +} +``` + +### Schema Definitions (Drizzle) + +Use snake_case for field names so column names don't need to be redefined as strings. + +```ts +// Good +const table = sqliteTable("session", { + id: text().primaryKey(), + project_id: text().notNull(), + created_at: integer().notNull(), +}) + +// Bad +const table = sqliteTable("session", { + id: text("id").primaryKey(), + projectID: text("project_id").notNull(), + createdAt: integer("created_at").notNull(), +}) +``` + +## Testing + +- Avoid mocks as much as possible +- Test actual implementation, do not duplicate logic into tests +- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. diff --git a/Agent-backend/CLAUDE.md b/Agent-backend/CLAUDE.md new file mode 100644 index 000000000..27804c390 --- /dev/null +++ b/Agent-backend/CLAUDE.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +OpenCode is an AI-powered coding agent (CLI and headless server) built with TypeScript and Bun. It's provider-agnostic, supporting Anthropic, OpenAI, Google, Azure, and many others via `@ai-sdk/*`. The default branch is `dev` (not `main`). + +This repo has been stripped to its engine core plus reference code from the upstream SolidJS web/desktop apps. See `docs/plans/STRIP-TO-SDK-AUDIT.md` for what was removed and why. + +## Commands + +```bash +# Install dependencies (Bun 1.3+ required) +bun install + +# Run CLI in development mode (TUI) +bun dev +bun dev # Run against specific directory +bun dev . # Run against repo root + +# Run headless API server +bun dev serve # Default port 4096 +bun dev serve --port 8080 + +# Type checking (all packages via Turbo) +bun turbo typecheck + +# Type checking (engine only — faster) +cd packages/opencode && bun run typecheck # Uses tsgo --noEmit + +# Tests (MUST run from package directory, not repo root) +cd packages/opencode && bun test --timeout 30000 +cd packages/opencode && bun test test/tool/bash.test.ts # Single test file + +# Build standalone executable +./packages/opencode/script/build.ts --single + +# Regenerate JS SDK after changing server/API code +./script/generate.ts + +# Database migrations +cd packages/opencode && bun drizzle-kit + +# Formatting (Prettier) +bunx prettier --check . # Check formatting +bunx prettier --write . # Auto-fix formatting +``` + +## Code Quality Status + +> **WARNING:** This codebase is significantly less strict than Orbit's main repo. + +### What exists + +| Tool | Config | What it checks | +| ----------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| **TypeScript** (`tsgo`) | `packages/opencode/tsconfig.json` | Type checking with `strict: true` via `@tsconfig/bun` | +| **Prettier** | Root `package.json` (`semi: false`, `printWidth: 120`) | Formatting only — no enforcement hook | +| **Pre-push hook** | `.husky/pre-push` | Runs `bun typecheck` before push. Checks Bun version matches `packageManager` field. | +| **Turbo** | `turbo.json` | Orchestrates `typecheck` and `build` tasks across packages | + +### What does NOT exist (vs Orbit) + +| Missing | Orbit has | Impact | +| ------------------------------ | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| **ESLint** | 15+ strict rules (no `any`, explicit returns, import order, no console, exhaustive switches) | No static analysis for unsafe patterns | +| **`noUncheckedIndexedAccess`** | Enabled | Disabled in opencode tsconfig — `array[0]` returns `T` not `T \| undefined` | +| **Knip** (dead code analysis) | Configured and running | No unused file/export detection | +| **Pre-commit hook** | Formatting + lint on commit | Only pre-push (typecheck only) | +| **CI pipeline** | `bun run ci` = typecheck + lint + tests + rust checks | No CI config (`.github/` removed) | +| **Structured logging** | `createLogger('Name')` required | `console.log` used directly (119 occurrences) | + +### Current violations (if Orbit's rules were applied) + +| Pattern | Count | Files | +| --------------------------------- | ------- | ----------------------------------------------------------------- | +| `: any` or `as any` | **136** | 46 files (worst: `provider/provider.ts` = 16, `util/log.ts` = 11) | +| `@ts-ignore` / `@ts-expect-error` | **20** | 14 files (worst: `provider/provider.ts` = 4) | +| `console.log/warn/error` | **119** | 21 files (mostly CLI code — acceptable in CLI, not in engine) | +| `eslint-disable` | 0 | N/A — no ESLint to disable | + +### TypeScript config details + +The engine (`packages/opencode/tsconfig.json`) extends `@tsconfig/bun` which provides: + +- `strict: true` (includes `strictNullChecks`, `noImplicitAny`, etc.) +- `noFallthroughCasesInSwitch: true` +- `noImplicitOverride: true` +- `verbatimModuleSyntax: true` + +But opencode OVERRIDES: + +- `noUncheckedIndexedAccess: false` — weakens null safety on indexed access +- `jsx: "preserve"` with `jsxImportSource: "@opentui/solid"` — SolidJS JSX for TUI + +## Architecture + +Bun workspace monorepo orchestrated with Turbo. After stripping: + +### Engine (actively used) + +- **`packages/opencode`** — CLI, API server, agent logic, tools, session management. **The heart.** +- **`packages/sdk/js`** — Auto-generated TypeScript SDK from OpenAPI spec. +- **`packages/plugin`** — Plugin SDK (`@opencode-ai/plugin`). +- **`packages/util`** — Shared utilities (array, encoding, error, path, retry, slug). +- **`packages/script`** — Build script utilities (semver). + +### Reference (read-only — SolidJS, not used by Orbit) + +- **`packages/app`** — Web UI (SolidJS). Reference for data flows, SDK usage, SSE patterns. +- **`packages/desktop`** — Tauri desktop app. Reference for sidecar lifecycle, native abstractions. +- **`packages/ui`** — 175 SolidJS components. Keeps `app/` imports resolvable. Theme/icon reference. + +### `packages/opencode/src/` internals + +| Directory | Purpose | +| ---------------- | ------------------------------------------------------------------------------------------------------ | +| `agent/` | Agent definitions and prompts (build agent, plan agent) | +| `cli/cmd/` | CLI commands (serve, web, tui, auth, mcp, etc.) | +| `cli/cmd/tui/` | Terminal UI built with SolidJS + [opentui](https://github.com/sst/opentui) | +| `provider/` | LLM provider integration via AI SDK | +| `tool/` | Built-in tools (bash, edit, read, write, grep, glob, etc.) — each has a `.ts` and `.txt` (prompt) file | +| `server/` | Hono-based API server with routes | +| `session/` | Conversation session management | +| `mcp/` | Model Context Protocol server/client | +| `config/` | Configuration loading and schema | +| `storage/` | Database layer (Drizzle ORM, SQLite) | +| `permission/` | Tool permission system | +| `control-plane/` | Agent orchestration | +| `skill/` | Custom skill/command system | +| `plugin/` | Plugin loading and execution | + +### Two built-in agents + +1. **build** — Full-access agent for development tasks (can use all tools) +2. **plan** — Read-only agent for analysis and exploration + +## Style Guide + +From `AGENTS.md` — these are conventions (advisory, NOT enforced by tooling): + +- **No semicolons** — Prettier config: `semi: false`, `printWidth: 120` +- **Single-word names** preferred for variables and functions +- **Inline single-use variables** — don't create intermediate vars +- **`const` over `let`** — use ternaries or early returns instead of reassignment +- **Early returns** — avoid `else` statements +- **No unnecessary destructuring** — use dot notation to preserve context +- **Avoid `try`/`catch`** — prefer `.catch(...)` when possible +- **Avoid `any`** — reach for precise types (NOT ENFORCED — 136 violations exist) +- **Bun APIs** — use `Bun.file()` and other Bun helpers when available +- **Drizzle schemas** — use snake_case for field names +- **Functional array methods** — prefer `flatMap`, `filter`, `map` over for loops +- **Avoid mocks in tests** — test actual implementations + +## Testing + +```bash +# Tests CANNOT run from repo root (guarded by bunfig.toml) +cd packages/opencode && bun test --timeout 30000 + +# Single test file +cd packages/opencode && bun test test/tool/bash.test.ts + +# Test directories mirror src/ structure +# test/tool/ → tool tests with snapshots and fixtures +# test/session/ → session lifecycle tests +# test/server/ → API route tests +# test/provider/ → provider integration tests +``` + +- **Framework:** Bun test (not Vitest) +- **Philosophy:** No mocks — test actual implementations +- **33 test directories** mirroring `src/` structure diff --git a/Agent-backend/bun.lock b/Agent-backend/bun.lock new file mode 100644 index 000000000..597cd88c8 --- /dev/null +++ b/Agent-backend/bun.lock @@ -0,0 +1,3495 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "opencode", + "dependencies": { + "@aws-sdk/client-s3": "3.933.0", + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/script": "workspace:*", + "@opencode-ai/sdk": "workspace:*", + "typescript": "catalog:", + }, + "devDependencies": { + "@actions/artifact": "5.0.1", + "@eslint/js": "10.0.1", + "@tsconfig/bun": "catalog:", + "@types/mime-types": "3.0.1", + "@typescript/native-preview": "catalog:", + "eslint": "10.0.3", + "eslint-plugin-import-x": "4.16.1", + "glob": "13.0.5", + "husky": "9.1.7", + "prettier": "3.6.2", + "semver": "^7.6.0", + "sst": "3.18.10", + "turbo": "2.8.13", + "typescript-eslint": "8.57.0", + }, + }, + "packages/app": { + "name": "@opencode-ai/app", + "version": "1.2.24", + "dependencies": { + "@kobalte/core": "catalog:", + "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@opencode-ai/util": "workspace:*", + "@shikijs/transformers": "3.9.2", + "@solid-primitives/active-element": "2.1.3", + "@solid-primitives/audio": "1.4.2", + "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/i18n": "2.2.1", + "@solid-primitives/media": "2.3.3", + "@solid-primitives/resize-observer": "2.1.3", + "@solid-primitives/scroll": "2.1.3", + "@solid-primitives/storage": "catalog:", + "@solid-primitives/websocket": "1.3.1", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "@thisbeyond/solid-dnd": "0.7.5", + "diff": "catalog:", + "fuzzysort": "catalog:", + "ghostty-web": "github:anomalyco/ghostty-web#main", + "luxon": "catalog:", + "marked": "catalog:", + "marked-shiki": "catalog:", + "remeda": "catalog:", + "shiki": "catalog:", + "solid-js": "catalog:", + "solid-list": "catalog:", + "tailwindcss": "catalog:", + "virtua": "catalog:", + "zod": "catalog:", + }, + "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "1.57.0", + "@tailwindcss/vite": "catalog:", + "@tsconfig/bun": "1.0.9", + "@types/bun": "catalog:", + "@types/luxon": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-icons-spritesheet": "3.0.1", + "vite-plugin-solid": "catalog:", + }, + }, + "packages/desktop": { + "name": "@opencode-ai/desktop", + "version": "1.2.24", + "dependencies": { + "@opencode-ai/app": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@solid-primitives/i18n": "2.2.1", + "@solid-primitives/storage": "catalog:", + "@solidjs/meta": "catalog:", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "~2", + "@tauri-apps/plugin-deep-link": "~2", + "@tauri-apps/plugin-dialog": "~2", + "@tauri-apps/plugin-http": "~2", + "@tauri-apps/plugin-notification": "~2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "~2", + "@tauri-apps/plugin-process": "~2", + "@tauri-apps/plugin-shell": "~2", + "@tauri-apps/plugin-store": "~2", + "@tauri-apps/plugin-updater": "~2", + "@tauri-apps/plugin-window-state": "~2", + "solid-js": "catalog:", + }, + "devDependencies": { + "@actions/artifact": "4.0.0", + "@tauri-apps/cli": "^2", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "~5.6.2", + "vite": "catalog:", + }, + }, + "packages/opencode": { + "name": "opencode", + "version": "1.2.24", + "bin": { + "opencode": "./bin/opencode", + "orbit": "./bin/opencode", + }, + "dependencies": { + "@actions/core": "1.11.1", + "@actions/github": "6.0.1", + "@agentclientprotocol/sdk": "0.14.1", + "@ai-sdk/amazon-bedrock": "3.0.82", + "@ai-sdk/anthropic": "2.0.65", + "@ai-sdk/azure": "2.0.91", + "@ai-sdk/cerebras": "1.0.36", + "@ai-sdk/cohere": "2.0.22", + "@ai-sdk/deepinfra": "1.0.36", + "@ai-sdk/gateway": "2.0.30", + "@ai-sdk/google": "2.0.54", + "@ai-sdk/google-vertex": "3.0.106", + "@ai-sdk/groq": "2.0.34", + "@ai-sdk/mistral": "2.0.27", + "@ai-sdk/openai": "2.0.89", + "@ai-sdk/openai-compatible": "1.0.32", + "@ai-sdk/perplexity": "2.0.23", + "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider-utils": "3.0.21", + "@ai-sdk/togetherai": "1.0.34", + "@ai-sdk/vercel": "1.0.33", + "@ai-sdk/xai": "2.0.51", + "@aws-sdk/credential-providers": "3.993.0", + "@clack/prompts": "1.0.0-alpha.1", + "@gitlab/gitlab-ai-provider": "3.6.0", + "@gitlab/opencode-gitlab-auth": "1.3.3", + "@hono/standard-validator": "0.1.5", + "@hono/zod-validator": "catalog:", + "@modelcontextprotocol/sdk": "1.25.2", + "@octokit/graphql": "9.0.2", + "@octokit/rest": "catalog:", + "@openauthjs/openauth": "catalog:", + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/script": "workspace:*", + "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/util": "workspace:*", + "@openrouter/ai-sdk-provider": "1.5.4", + "@opentui/core": "0.1.86", + "@opentui/solid": "0.1.86", + "@parcel/watcher": "2.5.1", + "@pierre/diffs": "catalog:", + "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/scheduled": "1.5.2", + "@standard-schema/spec": "1.0.0", + "@zip.js/zip.js": "2.7.62", + "ai": "catalog:", + "ai-gateway-provider": "2.3.1", + "bonjour-service": "1.3.0", + "bun-pty": "0.4.8", + "chokidar": "4.0.3", + "clipboardy": "4.0.0", + "decimal.js": "10.5.0", + "diff": "catalog:", + "drizzle-orm": "1.0.0-beta.16-ea816b6", + "fuzzysort": "3.1.0", + "glob": "13.0.5", + "google-auth-library": "10.5.0", + "gray-matter": "4.0.3", + "hono": "catalog:", + "hono-openapi": "catalog:", + "ignore": "7.0.5", + "jsonc-parser": "3.3.1", + "mime-types": "3.0.2", + "minimatch": "10.0.3", + "open": "10.1.2", + "opentui-spinner": "0.0.6", + "partial-json": "0.1.7", + "remeda": "catalog:", + "semver": "^7.6.3", + "solid-js": "catalog:", + "strip-ansi": "7.1.2", + "tree-sitter-bash": "0.25.0", + "turndown": "7.2.0", + "ulid": "catalog:", + "vscode-jsonrpc": "8.2.1", + "web-tree-sitter": "0.25.10", + "which": "6.0.1", + "xdg-basedir": "5.1.0", + "yargs": "18.0.0", + "zod": "catalog:", + "zod-to-json-schema": "3.24.5", + }, + "devDependencies": { + "@babel/core": "7.28.4", + "@octokit/webhooks-types": "7.6.1", + "@opencode-ai/script": "workspace:*", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1", + "@standard-schema/spec": "1.0.0", + "@tsconfig/bun": "catalog:", + "@types/babel__core": "7.20.5", + "@types/bun": "catalog:", + "@types/mime-types": "3.0.1", + "@types/semver": "7.7.1", + "@types/turndown": "5.0.5", + "@types/which": "3.0.4", + "@types/yargs": "17.0.33", + "@typescript/native-preview": "catalog:", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", + "typescript": "catalog:", + "vscode-languageserver-types": "3.17.5", + "why-is-node-running": "3.2.2", + "zod-to-json-schema": "3.24.5", + }, + }, + "packages/plugin": { + "name": "@opencode-ai/plugin", + "version": "1.2.24", + "dependencies": { + "@opencode-ai/sdk": "workspace:*", + "zod": "catalog:", + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, + "packages/script": { + "name": "@opencode-ai/script", + "dependencies": { + "semver": "^7.6.3", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@types/semver": "^7.5.8", + }, + }, + "packages/sdk/js": { + "name": "@opencode-ai/sdk", + "version": "1.2.24", + "devDependencies": { + "@hey-api/openapi-ts": "0.90.10", + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, + "packages/ui": { + "name": "@opencode-ai/ui", + "version": "1.2.24", + "dependencies": { + "@kobalte/core": "catalog:", + "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/util": "workspace:*", + "@pierre/diffs": "catalog:", + "@shikijs/transformers": "3.9.2", + "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/media": "2.3.3", + "@solid-primitives/resize-observer": "2.1.3", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "dompurify": "3.3.1", + "fuzzysort": "catalog:", + "katex": "0.16.27", + "luxon": "catalog:", + "marked": "catalog:", + "marked-katex-extension": "5.1.6", + "marked-shiki": "catalog:", + "morphdom": "2.7.8", + "motion": "12.34.5", + "motion-dom": "12.34.3", + "motion-utils": "12.29.2", + "remeda": "catalog:", + "shiki": "catalog:", + "solid-js": "catalog:", + "solid-list": "catalog:", + "strip-ansi": "7.1.2", + "virtua": "catalog:", + }, + "devDependencies": { + "@tailwindcss/vite": "catalog:", + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@types/katex": "0.16.7", + "@types/luxon": "catalog:", + "@typescript/native-preview": "catalog:", + "tailwindcss": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-icons-spritesheet": "3.0.1", + "vite-plugin-solid": "catalog:", + }, + }, + "packages/util": { + "name": "@opencode-ai/util", + "version": "1.2.24", + "dependencies": { + "zod": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, + }, + "trustedDependencies": [ + "esbuild", + "web-tree-sitter", + "tree-sitter-bash", + ], + "patchedDependencies": { + "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + }, + "overrides": { + "@types/bun": "catalog:", + "@types/node": "catalog:", + }, + "catalog": { + "@cloudflare/workers-types": "4.20251008.0", + "@hono/zod-validator": "0.4.2", + "@kobalte/core": "0.13.11", + "@octokit/rest": "22.0.0", + "@openauthjs/openauth": "0.0.0-20250322224806", + "@pierre/diffs": "1.1.0-beta.18", + "@playwright/test": "1.51.0", + "@solid-primitives/storage": "4.3.3", + "@solidjs/meta": "0.29.4", + "@solidjs/router": "0.15.4", + "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "@tailwindcss/vite": "4.1.11", + "@tsconfig/bun": "1.0.9", + "@tsconfig/node22": "22.0.2", + "@types/bun": "1.3.9", + "@types/luxon": "3.7.1", + "@types/node": "22.13.9", + "@types/semver": "7.7.1", + "@typescript/native-preview": "7.0.0-dev.20251207.1", + "ai": "5.0.124", + "diff": "8.0.2", + "dompurify": "3.3.1", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", + "fuzzysort": "3.1.0", + "hono": "4.10.7", + "hono-openapi": "1.1.2", + "luxon": "3.6.1", + "marked": "17.0.1", + "marked-shiki": "1.2.1", + "remeda": "2.26.0", + "shiki": "3.20.0", + "solid-js": "1.9.10", + "solid-list": "0.3.0", + "tailwindcss": "4.1.11", + "typescript": "5.8.2", + "ulid": "3.0.1", + "virtua": "0.42.3", + "vite": "7.1.4", + "vite-plugin-solid": "2.11.10", + "zod": "4.1.8", + }, + "packages": { + "@actions/artifact": ["@actions/artifact@5.0.1", "", { "dependencies": { "@actions/core": "^2.0.0", "@actions/github": "^6.0.1", "@actions/http-client": "^3.0.0", "@azure/storage-blob": "^12.29.1", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-dHJ5rHduhCKUikKTT9eXeWoUvfKia3IjR1sO/VTAV3DVAL4yMTRnl2iO5mcfiBjySHLwPNezwENAVskKYU5ymw=="], + + "@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], + + "@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="], + + "@actions/github": ["@actions/github@6.0.1", "", { "dependencies": { "@actions/http-client": "^2.2.0", "@octokit/core": "^5.0.1", "@octokit/plugin-paginate-rest": "^9.2.2", "@octokit/plugin-rest-endpoint-methods": "^10.4.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "undici": "^5.28.5" } }, "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw=="], + + "@actions/http-client": ["@actions/http-client@3.0.2", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^6.23.0" } }, "sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA=="], + + "@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="], + + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.82", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yb1EkRCMWex0tnpHPLGQxoJEiJvMGOizuxzlXFOpuGFiYgE679NsWE/F8pHwtoAWsqLlylgGAJvJDIJ8us8LEw=="], + + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.65", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HqTPP59mLQ9U6jXQcx6EORkdc5FyZu34Sitkg6jNpyMYcRjStvfx4+NWq/qaR+OTwBFcccv8hvVii0CYkH2Lag=="], + + "@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="], + + "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="], + + "@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="], + + "@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.24", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-E+wzGPSa/XHmajO3WtX8mtq0ewy04tsHSpU6/SGwqbiykwWba/emi7ayZ4ir89s5OzbAen2g7T9zZiEchMfkHQ=="], + + "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.33", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LndvRktEgY2IFu4peDJMEXcjhHEEFtM0upLx/J64kCpFHCifalXpK4PPSX3PVndnn0bJzvamO5+fc0z2ooqBZw=="], + + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.35", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qvh2yxL5zJS9RO/Bf12pyYBIDmn+9GR1hT6e28IYWQWnt2Xq0h9XGps6XagLAv3VYYFg8c/ozkWVd4kXLZ25HA=="], + + "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.24", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ee2At5jgV+SqC6nrtPq20iH7N/aN+O36LrA4gkzVM4cmhM7bvQKVkOXhC1XxG+wsYG6UZi3Nekoi8MEjNWuRrw=="], + + "@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.35", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.34", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-inUq29XvSVDer6JIeOkwAmCFxOtHPU0OZEhwaWoe3PI59naHIW4RIFA9wppLLV5fJI9WQcAfDKy0ZHW9nV3UJw=="], + + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="], + + "@ai-sdk/google": ["@ai-sdk/google@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VKguP0x/PUYpdQyuA/uy5pDGJy6reL0X/yDKxHfL207aCUXpFIBmyMhVs4US39dkEVhtmIFSwXauY0Pt170JRw=="], + + "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.106", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/google": "2.0.54", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-f9sA66bmhgJoTwa+pHWFSdYxPa0lgdQ/MgYNxZptzVyGptoziTf1a9EIXEL3jiCD0qIBAg+IhDAaYalbvZaDqQ=="], + + "@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="], + + "@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gaptHgaXjMw3+eA0Q4FABcsj5nQNP6EpFaGUR+Pj5WJy7Kn6mApl975/x57224MfeJIShNpt8wFKK3tvh5ewKg=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="], + + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], + + "@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-aiaRvnc6mhQZKhTTSXPCjPH8Iqr5D/PfCN1hgVP/3RGTBbJtsd9HemIBSABeSdAKbsMH/PwJxgnqH75HEamcBA=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="], + + "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="], + + "@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="], + + "@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="], + + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + + "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="], + + "@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.993.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.11", "@aws-sdk/credential-provider-node": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.993.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.9", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.2", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-retry": "^4.4.33", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.32", "@smithy/util-defaults-mode-node": "^4.2.35", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-7Ne3Yk/bgQPVebAkv7W+RfhiwTRSbfER9BtbhOa2w/+dIr902LrJf6vrZlxiqaJbGj2ALx8M+ZK1YIHVxSwu9A=="], + + "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.933.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-bucket-endpoint": "3.930.0", "@aws-sdk/middleware-expect-continue": "3.930.0", "@aws-sdk/middleware-flexible-checksums": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-location-constraint": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-sdk-s3": "3.932.0", "@aws-sdk/middleware-ssec": "3.930.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/signature-v4-multi-region": "3.932.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-blob-browser": "^4.2.6", "@smithy/hash-node": "^4.2.5", "@smithy/hash-stream-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/md5-js": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-KxwZvdxdCeWK6o8mpnb+kk7Kgb8V+8AjTwSXUWH1UAD85B0tjdo1cSfE5zoR5fWGol4Ml5RLez12a6LPhsoTqA=="], + + "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zwGLSiK48z3PzKpQiDMKP85+fpIrPMF1qQOQW9OW7BGj5AuBZIisT2O4VzIgYJeh+t47MLU7VgBQL7muc+MJDg=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.932.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@aws-sdk/xml-builder": "3.930.0", "@smithy/core": "^3.18.2", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-AS8gypYQCbNojwgjvZGkJocC2CoEICDx9ZJ15ILsv+MlcCVLtUJSRSx3VzJOUY2EEIaGLRrPNlIqyn/9/fySvA=="], + + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.11", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-yHBjinYauxSvikf15EtgXyZ9TBIMVHUSWFPycQtPltTINpK+uv6K22zKkVsbxpB0gvsdRdIWP0UG5gejM+jPuQ=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-login": "^3.972.18", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.18", "@aws-sdk/credential-provider-web-identity": "^3.972.18", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.933.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.932.0", "@aws-sdk/credential-provider-http": "3.932.0", "@aws-sdk/credential-provider-ini": "3.933.0", "@aws-sdk/credential-provider-process": "3.932.0", "@aws-sdk/credential-provider-sso": "3.933.0", "@aws-sdk/credential-provider-web-identity": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-L2dE0Y7iMLammQewPKNeEh1z/fdJyYEU+/QsLBD9VEh+SXcN/FIyTi21Isw8wPZN6lMB9PDVtISzBnF8HuSFrw=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/token-providers": "3.1005.0", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g=="], + + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.993.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.993.0", "@aws-sdk/core": "^3.973.11", "@aws-sdk/credential-provider-cognito-identity": "^3.972.3", "@aws-sdk/credential-provider-env": "^3.972.9", "@aws-sdk/credential-provider-http": "^3.972.11", "@aws-sdk/credential-provider-ini": "^3.972.9", "@aws-sdk/credential-provider-login": "^3.972.9", "@aws-sdk/credential-provider-node": "^3.972.10", "@aws-sdk/credential-provider-process": "^3.972.9", "@aws-sdk/credential-provider-sso": "^3.972.9", "@aws-sdk/credential-provider-web-identity": "^3.972.9", "@aws-sdk/nested-clients": "3.993.0", "@aws-sdk/types": "^3.973.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.2", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-1M/nukgPSLqe9krzOKHnE8OylUaKAiokAV3xRLdeExVHcRE7WG5uzCTKWTj1imKvPjDqXq/FWhlbbdWIn7xIwA=="], + + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-cnCLWeKPYgvV4yRYPFH6pWMdUByvu2cy2BAlfsPpvnm4RaVioztyvxmQj5PmVN5fvWs5w/2d6U7le8X9iye2sA=="], + + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-5HEQ+JU4DrLNWeY27wKg/jeVa8Suy62ivJHOSUf6e6hZdVIMx0h/kXS1fHEQNNiLu2IzSEP/bFXsKBaW7x7s0g=="], + + "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.932.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-hyvRz/XS/0HTHp9/Ld1mKwpOi7bZu5olI42+T112rkCTbt1bewkygzEl4oflY4H7cKMamQusYoL0yBUD/QSEvA=="], + + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-x30jmm3TLu7b/b+67nMyoV0NlbnCVT5DI57yDrhXAPCtdgM1KtdLWt45UcHpKOm1JsaIkmYRh2WYu7Anx4MG0g=="], + + "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-QIGNsNUdRICog+LYqmtJ03PLze6h2KCORXUs5td/hAEjVP5DMmubhtrGg1KhWyctACluUH/E/yrD14p4pRXxwA=="], + + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-vh4JBWzMCBW8wREvAwoSqB2geKsZwSHTa0nSt0OMOLp2PdTYIZDi0ZiVMmpfnjcx9XbS6aSluLv9sKx4RrG46A=="], + + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.933.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@aws/lambda-invoke-store": "^0.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-qgrMlkVKzTCAdNw2A05DC2sPBo0KRQ7wk+lbYSRJnWVzcrceJhnmhoZVV5PFv7JtchK7sHVcfm9lcpiyd+XaCA=="], + + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/core": "^3.18.2", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-bYMHxqQzseaAP9Z5qLI918z5AtbAnZRRtFi3POb4FLZyreBMgCgBNaPkIhdgywnkqaydTWvbMBX4s9f4gUwlTw=="], + + "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-N2/SvodmaDS6h7CWfuapt3oJyn1T2CBz0CsDIiTDv9cSagXAVFjPdm2g4PFJqrNBeqdDIoYBnnta336HmamWHg=="], + + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@smithy/core": "^3.18.2", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-9BGTbJyA/4PTdwQWE9hAFIJGpsYkyEW20WON3i15aDqo5oRZwZmqaVageOD57YYqG8JDJjvcwKyDdR4cc38dvg=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.993.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.11", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.993.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.9", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.2", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-retry": "^4.4.33", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.32", "@smithy/util-defaults-mode-node": "^4.2.35", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ=="], + + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/config-resolver": "^4.4.3", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-KL2JZqH6aYeQssu1g1KuWsReupdfOoxD6f1as2VC+rdwYFUu4LfzMsFfXnBvvQWWqQ7rZHWOw1T+o5gJmg7Dzw=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.932.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-NCIRJvoRc9246RZHIusY1+n/neeG2yGhBGdKhghmrNdM+mLLN6Ii7CKFZjx3DhxtpHMpl1HWLTMhdVrGwP2upw=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1005.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.930.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-we/vaAgwlEFW7IeftmCLlLMw+6hFs3DzZPJw7lVHbj/5HJ0bz9gndxEsS2lQoeJ1zhiiLqAqvXxmM43s0MBg0A=="], + + "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA=="], + + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-endpoints": "^3.2.5", "tslib": "^2.6.2" } }, "sha512-M2oEKBzzNAYr136RRc6uqw3aWlwCxqTP1Lawps9E1d2abRPvl1p1ztQmmXp1Ak4rv8eByIZ+yQyKQ3zPdRG5dw=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], + + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/types": "^4.9.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-q6lCRm6UAe+e1LguM5E4EqM9brQlDem4XDcQ87NzEvlTW6GzmNCO0w1jS0XgCFXQHjDxjdlNFX+5sRbHijwklg=="], + + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.932.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-/kC6cscHrZL74TrZtgiIL5jJNbVsw9duGGPurmaVgoCbP7NnxyaSWEurbNV3VPNPhNE3bV3g4Ci+odq+AlsYQg=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.930.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], + + "@azure-rest/core-client": ["@azure-rest/core-client@2.5.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A=="], + + "@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], + + "@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], + + "@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], + + "@azure/core-http": ["@azure/core-http@3.0.5", "", { "dependencies": { "@azure/abort-controller": "^1.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-tracing": "1.0.0-preview.13", "@azure/core-util": "^1.1.1", "@azure/logger": "^1.0.0", "@types/node-fetch": "^2.5.0", "@types/tunnel": "^0.0.3", "form-data": "^4.0.0", "node-fetch": "^2.6.7", "process": "^0.11.10", "tslib": "^2.2.0", "tunnel": "^0.0.6", "uuid": "^8.3.0", "xml2js": "^0.5.0" } }, "sha512-T8r2q/c3DxNu6mEJfPuJtptUVqwchxzjj32gKcnMi06rdiVONS9rar7kT9T2Am+XvER7uOzpsP79WsqNbdgdWg=="], + + "@azure/core-http-compat": ["@azure/core-http-compat@2.3.2", "", { "dependencies": { "@azure/abort-controller": "^2.1.2" }, "peerDependencies": { "@azure/core-client": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0" } }, "sha512-Tf6ltdKzOJEgxZeWLCjMxrxbodB/ZeCbzzA1A2qHbhzAjzjHoBVSUeSl/baT/oHAxhc4qdqVaDKnc2+iE932gw=="], + + "@azure/core-lro": ["@azure/core-lro@2.7.2", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" } }, "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw=="], + + "@azure/core-paging": ["@azure/core-paging@1.6.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA=="], + + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.23.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ=="], + + "@azure/core-tracing": ["@azure/core-tracing@1.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="], + + "@azure/core-util": ["@azure/core-util@1.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="], + + "@azure/core-xml": ["@azure/core-xml@1.5.0", "", { "dependencies": { "fast-xml-parser": "^5.0.7", "tslib": "^2.8.1" } }, "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw=="], + + "@azure/identity": ["@azure/identity@4.13.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^4.2.0", "@azure/msal-node": "^3.5.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw=="], + + "@azure/keyvault-common": ["@azure/keyvault-common@2.0.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", "@azure/core-rest-pipeline": "^1.8.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.10.0", "@azure/logger": "^1.1.4", "tslib": "^2.2.0" } }, "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w=="], + + "@azure/keyvault-keys": ["@azure/keyvault-keys@4.10.0", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.7.2", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/keyvault-common": "^2.0.0", "@azure/logger": "^1.1.4", "tslib": "^2.8.1" } }, "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag=="], + + "@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], + + "@azure/msal-browser": ["@azure/msal-browser@4.29.0", "", { "dependencies": { "@azure/msal-common": "15.15.0" } }, "sha512-/f3eHkSNUTl6DLQHm+bKecjBKcRQxbd/XLx8lvSYp8Nl/HRyPuIPOijt9Dt0sH50/SxOwQ62RnFCmFlGK+bR/w=="], + + "@azure/msal-common": ["@azure/msal-common@15.15.0", "", {}, "sha512-/n+bN0AKlVa+AOcETkJSKj38+bvFs78BaP4rNtv3MJCmPH0YrHiskMRe74OhyZ5DZjGISlFyxqvf9/4QVEi2tw=="], + + "@azure/msal-node": ["@azure/msal-node@3.8.8", "", { "dependencies": { "@azure/msal-common": "15.15.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-+f1VrJH1iI517t4zgmuhqORja0bL6LDQXfBqkjuMmfTYXTQQnh1EvwwxO3UbKLT05N0obF72SRHFrC1RBDv5Gg=="], + + "@azure/storage-blob": ["@azure/storage-blob@12.31.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.3", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.2.0", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.5", "@azure/logger": "^1.1.4", "@azure/storage-common": "^12.3.0", "events": "^3.0.0", "tslib": "^2.8.1" } }, "sha512-DBgNv10aCSxopt92DkTDD0o9xScXeBqPKGmR50FPZQaEcH4JLQ+GEOGEDv19V5BMkB7kxr+m4h6il/cCDPvmHg=="], + + "@azure/storage-common": ["@azure/storage-common@12.3.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.2.0", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.1.4", "events": "^3.3.0", "tslib": "^2.8.1" } }, "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.11.0", "", {}, "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ=="], + + "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.11.0", "", { "dependencies": { "@bufbuild/protobuf": "2.11.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ=="], + + "@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="], + + "@clack/prompts": ["@clack/prompts@1.0.0-alpha.1", "", { "dependencies": { "@clack/core": "1.0.0-alpha.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-07MNT0OsxjKOcyVfX8KhXBhJiyUbDP1vuIAcHc+nx5v93MJO23pX3X/k3bWz6T3rpM9dgWPq90i4Jq7gZAyMbw=="], + + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], + + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], + + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.3", "", { "dependencies": { "@eslint/core": "^1.1.1" } }, "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw=="], + + "@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], + + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], + + "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], + + "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], + + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], + + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], + + "@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="], + + "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="], + + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], + + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.5.5", "", { "dependencies": { "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-f2ZHucnA2wBGAY8ipB4wn/mrEYW+WUxU2huJmUvfDO6AE2vfILSHeF3wCO39Pz4wUYPoAWZByaauftLrOfC12Q=="], + + "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-oS+5yAdwnK20lSeFO1d53Ku+yaGCsY8PcrmSq2GtSs3bsBfRnHAbpPKSVzQcaxAOrzj5NB+f34WhZglVrNayBA=="], + + "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.90.10", "", { "dependencies": { "@hey-api/codegen-core": "^0.5.5", "@hey-api/json-schema-ref-parser": "1.2.2", "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-o0wlFxuLt1bcyIV/ZH8DQ1wrgODTnUYj/VfCHOOYgXUQlLp9Dm2PjihOz+WYrZLowhqUhSKeJRArOGzvLuOTsg=="], + + "@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="], + + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + + "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], + + "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="], + + "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="], + + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], + + "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], + + "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], + + "@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="], + + "@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="], + + "@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="], + + "@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="], + + "@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="], + + "@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="], + + "@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="], + + "@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="], + + "@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="], + + "@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="], + + "@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="], + + "@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="], + + "@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="], + + "@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="], + + "@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="], + + "@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="], + + "@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="], + + "@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="], + + "@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="], + + "@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="], + + "@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="], + + "@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="], + + "@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="], + + "@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="], + + "@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="], + + "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@js-joda/core": ["@js-joda/core@5.7.0", "", {}, "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg=="], + + "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], + + "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + + "@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="], + + "@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="], + + "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], + + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + + "@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="], + + "@octokit/core": ["@octokit/core@5.2.2", "", { "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.0.0", "before-after-hook": "^2.2.0", "universal-user-agent": "^6.0.0" } }, "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg=="], + + "@octokit/endpoint": ["@octokit/endpoint@9.0.6", "", { "dependencies": { "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.2", "", { "dependencies": { "@octokit/request": "^10.0.4", "@octokit/types": "^15.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-iz6KzZ7u95Fzy9Nt2L8cG88lGRMr/qy1Q36ih/XVzMIlPDMYwaNLE/ENhqmIzgPrlNWiYJkwmveEetvxAgFBJw=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@9.2.2", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@1.0.4", "", { "peerDependencies": { "@octokit/core": ">=3" } }, "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@10.4.1", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg=="], + + "@octokit/plugin-retry": ["@octokit/plugin-retry@3.0.9", "", { "dependencies": { "@octokit/types": "^6.0.3", "bottleneck": "^2.15.3" } }, "sha512-r+fArdP5+TG6l1Rv/C9hVoty6tldw6cE2pRHNGmFPdyfrc696R6JjrQ3d7HdVqGwuzfyrcaLAKD7K8TX8aehUQ=="], + + "@octokit/request": ["@octokit/request@8.4.1", "", { "dependencies": { "@octokit/endpoint": "^9.0.6", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw=="], + + "@octokit/request-error": ["@octokit/request-error@5.1.1", "", { "dependencies": { "@octokit/types": "^13.1.0", "deprecation": "^2.0.0", "once": "^1.4.0" } }, "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g=="], + + "@octokit/rest": ["@octokit/rest@22.0.0", "", { "dependencies": { "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.0.1", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0" } }, "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA=="], + + "@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="], + + "@octokit/webhooks-types": ["@octokit/webhooks-types@7.6.1", "", {}, "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw=="], + + "@openauthjs/openauth": ["@openauthjs/openauth@0.0.0-20250322224806", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-p5IWSRXvABcwocH2dNI0w8c1QJelIOFulwhKk+aLLFfUbs8u1pr7kQbYe8yCSM2+bcLHiwbogpUQc2ovrGwCuw=="], + + "@opencode-ai/app": ["@opencode-ai/app@workspace:packages/app"], + + "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], + + "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], + + "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], + + "@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"], + + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="], + + "@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentui/core": ["@opentui/core@0.1.86", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.86", "@opentui/core-darwin-x64": "0.1.86", "@opentui/core-linux-arm64": "0.1.86", "@opentui/core-linux-x64": "0.1.86", "@opentui/core-win32-arm64": "0.1.86", "@opentui/core-win32-x64": "0.1.86", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-3tRLbI9ADrQE1jEEn4x2aJexEOQZkv9Emk2BixMZqxfVhz2zr2SxtpimDAX0vmZK3+GnWAwBWxuaCAsxZpY4+w=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.86", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zp7q64+d+Dcx6YrH3mRcnHq8EOBnrfc1RvjgSWLhpXr49hY6LzuhqpfZM57aGErPYlR+ff8QM6e5FUkFnDfyjw=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.86", "", { "os": "darwin", "cpu": "x64" }, "sha512-NcxfjCJm1kLnTMVOpAPdRYNi8W8XdAXNa6N7i9khiVFrl2v5KRQfUjbrSOUYVxFJNc3jKFG6rsn3jEApvn92qA=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.86", "", { "os": "linux", "cpu": "arm64" }, "sha512-EDHAvqSOr8CXzbDvo1aE5blJ6wu1aSbR2LqoXtoeXHemr2T2W42D2TdIWewG6K+/BuRbzZnqt9wnYFBksLW6lw=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.86", "", { "os": "linux", "cpu": "x64" }, "sha512-VBaBkVdQDxYV4WcKjb+jgyMS5PiVHepvfaoKWpz1Bq+J01xXW4XPcXyPGkgR1+2R93KzaugEnLscTW4mWtLHlQ=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.86", "", { "os": "win32", "cpu": "arm64" }, "sha512-xKbT7sEKYKGwUPkoqmLfHjbJU+vwHPDwf/r/mIunL41JXQBB35CSZ3/QgIwpp2kkteu7oE1tdBdg15ogUU4OMg=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.86", "", { "os": "win32", "cpu": "x64" }, "sha512-HRfgAUlcu71/MrtgfX4Gj7PsDtfXZiuC506Pkn1OnRN1Xomcu10BVRDweUa0/g8ldU9i9kLjMGGnpw6/NjaBFg=="], + + "@opentui/solid": ["@opentui/solid@0.1.86", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.86", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-pOZC9dlZIH+bpstVVZ2AvYukBnslZTKSl/y5H8FWcMTHGv/BzpGxXBxstL65E/IQASqPFbvFcs7yMRzdLhynmA=="], + + "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], + + "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], + + "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], + + "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + + "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="], + + "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + + "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="], + + "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], + + "@protobuf-ts/runtime": ["@protobuf-ts/runtime@2.11.1", "", {}, "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ=="], + + "@protobuf-ts/runtime-rpc": ["@protobuf-ts/runtime-rpc@2.11.1", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1" } }, "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + + "@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + + "@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + + "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], + + "@shikijs/types": ["@shikijs/types@3.9.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@smithy/abort-controller": ["@smithy/abort-controller@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ=="], + + "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="], + + "@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="], + + "@smithy/config-resolver": ["@smithy/config-resolver@4.4.10", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg=="], + + "@smithy/core": ["@smithy/core@3.23.9", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.12", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.11", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.11", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w=="], + + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.11", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA=="], + + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA=="], + + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.11", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw=="], + + "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.11", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ=="], + + "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.12", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-1wQE33DsxkM/waftAhCH9VtJbUGyt1PJ9YRDpOu+q9FUi73LLFUZ2fD8A61g2mT1UY9k7b99+V1xZ41Rz4SHRQ=="], + + "@smithy/hash-node": ["@smithy/hash-node@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A=="], + + "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-hQsTjwPCRY8w9GK07w1RqJi3e+myh0UaOWBBhZ1UMSDgofH/Q1fEYzU1teaX6HkpX/eWDdm7tAGR0jBPlz9QEQ=="], + + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + + "@smithy/md5-js": ["@smithy/md5-js@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-350X4kGIrty0Snx2OWv7rPM6p6vM7RzryvFs6B/56Cux3w3sChOb3bymo5oidXJlPcP9fIRxGUCk7GqpiSOtng=="], + + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw=="], + + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.23", "", { "dependencies": { "@smithy/core": "^3.23.9", "@smithy/middleware-serde": "^4.2.12", "@smithy/node-config-provider": "^4.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw=="], + + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.40", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/service-error-classification": "^4.2.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA=="], + + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng=="], + + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg=="], + + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.11", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.14", "", { "dependencies": { "@smithy/abort-controller": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A=="], + + "@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@smithy/protocol-http": ["@smithy/protocol-http@5.3.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ=="], + + "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA=="], + + "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ=="], + + "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw=="], + + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.6", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.3.11", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ=="], + + "@smithy/smithy-client": ["@smithy/smithy-client@4.12.3", "", { "dependencies": { "@smithy/core": "^3.23.9", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-stack": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw=="], + + "@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + + "@smithy/url-parser": ["@smithy/url-parser@4.2.11", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing=="], + + "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="], + + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="], + + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + + "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="], + + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.39", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ=="], + + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.42", "", { "dependencies": { "@smithy/config-resolver": "^4.4.10", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A=="], + + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], + + "@smithy/util-middleware": ["@smithy/util-middleware@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw=="], + + "@smithy/util-retry": ["@smithy/util-retry@4.2.11", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw=="], + + "@smithy/util-stream": ["@smithy/util-stream@4.5.17", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ=="], + + "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + + "@smithy/util-waiter": ["@smithy/util-waiter@4.2.11", "", { "dependencies": { "@smithy/abort-controller": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x7Rh2azQPs3XxbvCzcttRErKKvLnbZfqRf/gOjw2pb+ZscX88e5UkRPCB67bVnsFHxayvMvmePfKTqsRb+is1A=="], + + "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + + "@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="], + + "@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="], + + "@solid-primitives/bounds": ["@solid-primitives/bounds@0.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/resize-observer": "^2.1.3", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UbiyKMdSPmtijcEDnYLQL3zzaejpwWDAJJ4Gt5P0hgVs6A72piov0GyNw7V2SroH7NZFwxlYS22YmOr8A5xc1Q=="], + + "@solid-primitives/event-bus": ["@solid-primitives/event-bus@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="], + + "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="], + + "@solid-primitives/i18n": ["@solid-primitives/i18n@2.2.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TnTnE2Ku11MGYZ1JzhJ8pYscwg1fr9MteoYxPwsfxWfh9Jp5K7RRJncJn9BhOHvNLwROjqOHZ46PT7sPHqbcXw=="], + + "@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="], + + "@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="], + + "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], + + "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="], + + "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="], + + "@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], + + "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], + + "@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], + + "@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="], + + "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ=="], + + "@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="], + + "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="], + + "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], + + "@solid-primitives/websocket": ["@solid-primitives/websocket@1.3.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-F06tA2FKa5VsnS4E4WEc3jHpsJfXRlMTGOtolugTzCqV3JmJTyvk9UVg1oz6PgGHKGi1CQ91OP8iW34myyJgaQ=="], + + "@solidjs/meta": ["@solidjs/meta@0.29.4", "", { "peerDependencies": { "solid-js": ">=1.8.4" } }, "sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g=="], + + "@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="], + + "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], + + "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + + "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.11", "", { "os": "android", "cpu": "arm64" }, "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11", "", { "os": "linux", "cpu": "arm" }, "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="], + + "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], + + "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], + + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ=="], + + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw=="], + + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w=="], + + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA=="], + + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg=="], + + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.1", "", { "os": "linux", "cpu": "none" }, "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw=="], + + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw=="], + + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ=="], + + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg=="], + + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw=="], + + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], + + "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + + "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.7", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg=="], + + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="], + + "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.7", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A=="], + + "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], + + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="], + + "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], + + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], + + "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], + + "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A=="], + + "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ=="], + + "@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="], + + "@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="], + + "@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="], + + "@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], + + "@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], + + "@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="], + + "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], + + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + + "@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="], + + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="], + + "@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/utils": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="], + + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20251207.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251207.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251207.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20251207.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251207.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20251207.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251207.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20251207.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-4QcRnzB0pi9rS0AOvg8kWbmuwHv5X7B2EXHbgcms9+56hsZ8SZrZjNgBJb2rUIodJ4kU5mrkj/xlTTT4r9VcpQ=="], + + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20251207.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-waWJnuuvkXh4WdpbTjYf7pyahJzx0ycesV2BylyHrE9OxU9FSKcD/cRLQYvbq3YcBSdF7sZwRLDBer7qTeLsYA=="], + + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20251207.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-3bkD9QuIjxETtp6J1l5X2oKgudJ8z+8fwUq0izCjK1JrIs2vW1aQnbzxhynErSyHWH7URGhHHzcsXHbikckAsg=="], + + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20251207.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OjrZBq8XJkB7uCQvT1AZ1FPsp+lT0cHxY5SisE+ZTAU6V0IHAZMwJ7J/mnwlGsBcCKRLBT+lX3hgEuOTSwHr9w=="], + + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20251207.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qhp06OObkwy5B+PlAhAmq+Ls3GVt4LHAovrTRcpLB3Mk3yJ0h9DnIQwPQiayp16TdvTsGHI3jdIX4MGm5L/ghA=="], + + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20251207.1", "", { "os": "linux", "cpu": "x64" }, "sha512-fPRw0zfTBeVmrkgi5Le+sSwoeAz6pIdvcsa1OYZcrspueS9hn3qSC5bLEc5yX4NJP1vItadBqyGLUQ7u8FJjow=="], + + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20251207.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-KxY1i+HxeSFfzZ+HVsKwMGBM79laTRZv1ibFqHu22CEsfSPDt4yiV1QFis8Nw7OBXswNqJG/UGqY47VP8FeTvw=="], + + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251207.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5l51HlXjX7lXwo65DEl1IaCFLjmkMtL6K3NrSEamPNeNTtTQwZRa3pQ9V65dCglnnCQ0M3+VF1RqzC7FU0iDKg=="], + + "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], + + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.4", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + + "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + + "@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="], + + "ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + + "archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="], + + "archiver-utils": ["archiver-utils@5.0.2", "", { "dependencies": { "glob": "^10.0.0", "graceful-fs": "^4.2.0", "is-stream": "^2.0.1", "lazystream": "^1.0.0", "lodash": "^4.17.15", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA=="], + + "arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="], + + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], + + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + + "aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="], + + "aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="], + + "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], + + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="], + + "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], + + "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + + "bare-fs": ["bare-fs@4.5.5", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w=="], + + "bare-os": ["bare-os@3.7.1", "", {}, "sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA=="], + + "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + + "bare-stream": ["bare-stream@2.8.1", "", { "dependencies": { "streamx": "^2.21.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg=="], + + "bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + + "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="], + + "bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="], + + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="], + + "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], + + "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + + "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw=="], + + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A=="], + + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g=="], + + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + + "clipboardy": ["clipboardy@4.0.0", "", { "dependencies": { "execa": "^8.0.1", "is-wsl": "^3.1.0", "is64bit": "^2.0.0" } }, "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + + "comment-parser": ["comment-parser@1.4.5", "", {}, "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw=="], + + "compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "crc32-stream": ["crc32-stream@6.0.0", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g=="], + + "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.5.0", "", {}, "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + + "drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="], + + "drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.0.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ=="], + + "eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="], + + "eslint-plugin-import-x": ["eslint-plugin-import-x@4.16.1", "", { "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.0.1", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.0.0", "eslint": "^8.57.0 || ^9.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + + "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-json-stringify": ["fast-json-stringify@6.3.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fast-xml-builder": ["fast-xml-builder@1.1.0", "", { "dependencies": { "path-expression-matcher": "^1.1.2" } }, "sha512-7mtITW/we2/wTUZqMyBOR2F8xP4CRxMiSEcQxPIqdRWdO2L/HZSOlzoNyghmyDwNB8BDxePooV1ZTJpkOUhdRg=="], + + "fast-xml-parser": ["fast-xml-parser@5.4.2", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ=="], + + "fastify": ["fastify@5.8.2", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg=="], + + "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], + + "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.1", "", {}, "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "framer-motion": ["framer-motion@12.35.2", "", { "dependencies": { "motion-dom": "^12.35.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + + "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], + + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], + + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + + "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], + + "graphql-request": ["graphql-request@6.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "cross-fetch": "^3.1.5" }, "peerDependencies": { "graphql": "14 - 16" } }, "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw=="], + + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "happy-dom": ["happy-dom@20.8.3", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], + + "hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="], + + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], + + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], + + "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], + + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="], + + "jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], + + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + + "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json-with-bigint": ["json-with-bigint@3.5.7", "", {}, "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="], + + "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], + + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + + "luxon": ["luxon@3.6.1", "", {}, "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], + + "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], + + "minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + + "morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="], + + "motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="], + + "motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="], + + "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mssql": ["mssql@11.0.1", "", { "dependencies": { "@tediousjs/connection-string": "^0.5.0", "commander": "^11.0.0", "debug": "^4.3.3", "rfdc": "^1.3.0", "tarn": "^3.0.2", "tedious": "^18.2.1" }, "bin": { "mssql": "bin/mssql" } }, "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w=="], + + "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="], + + "nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], + + "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="], + + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="], + + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + + "open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], + + "openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "opencode": ["opencode@workspace:packages/opencode"], + + "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], + + "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], + + "opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], + + "parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="], + + "parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-expression-matcher": ["path-expression-matcher@1.1.2", "", {}, "sha512-LXWqJmcpp2BKOEmgt4CyuESFmBfPuhJlAHKJsFzuJU6CxErWk75BrO+Ni77M9OxHN6dCYKM4vj+21Z6cOL96YQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + + "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], + + "planck": ["planck@1.4.3", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA=="], + + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + + "querystring": ["querystring@0.2.0", "", {}, "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + + "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], + + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="], + + "seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], + + "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="], + + "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + + "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="], + + "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "sst": ["sst@3.18.10", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.18.10", "sst-darwin-x64": "3.18.10", "sst-linux-arm64": "3.18.10", "sst-linux-x64": "3.18.10", "sst-linux-x86": "3.18.10", "sst-win32-arm64": "3.18.10", "sst-win32-x64": "3.18.10", "sst-win32-x86": "3.18.10" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-SY+ldeJ9K5E9q+DhjXA3e2W3BEOzBwkE3IyLSD71uA3/5nRhUAST31iOWEpW36LbIvSQ9uOVDFcebztoLJ8s7w=="], + + "sst-darwin-arm64": ["sst-darwin-arm64@3.18.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3MwIpMZhhdZKDqLp9ZQNlwkWix5+q+N0PWstuTomYwgZOxCCe6u9IIsoIszSk+GAJJN/jvGZyLiXKeV4iiQvw=="], + + "sst-darwin-x64": ["sst-darwin-x64@3.18.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-nQ0jMKkPOa+kj6Ygz8+kYhBua/vgNTLkd+4r8NSmk7v+Zs78lKnx3T//kEzS0yik6Q6QwGfokwrTcA1Jii2xSw=="], + + "sst-linux-arm64": ["sst-linux-arm64@3.18.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-mj9VNj3SvLS+HaXx2PhCX0aTA7CwJNoM6JhRc0s/zCilqchcvqDjbhpYBJO4brEPv6aOaaa7T3WvIQqtYauK4Q=="], + + "sst-linux-x64": ["sst-linux-x64@3.18.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7iy1Eq2eqnT9Ag/8OVgC04vRjV7AAQyf/BvzLc+6Sz+GvRiKA8VEuPnbXNYQF+NIvEqsawfcd7MknSTtImpsvQ=="], + + "sst-linux-x86": ["sst-linux-x86@3.18.10", "", { "os": "linux", "cpu": "none" }, "sha512-77qZSuPZeQ5bdRCiq1pQEdY8EcGNHboKrx4P2yFid2FBDKJsXxOXtIxJdloyx+ljBn0+nxl/g040QBmXxdc9tA=="], + + "sst-win32-arm64": ["sst-win32-arm64@3.18.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-aY+FhMxvYs8crlrKALpLn/kKmud8YQj6LkMHsrOAAIJhfNyxhCja2vrYQaY+bcqdsS5W2LMVcS2hyaMqKXZKcg=="], + + "sst-win32-x64": ["sst-win32-x64@3.18.10", "", { "os": "win32", "cpu": "x64" }, "sha512-rY+yJXOpG+P5xXnaQRpCvBK2zwwLhjzpYidGkp6F+cGgiVdh2Wre/CIQNRaVHr20ncj8lLe/RsHWa9QCNM48jg=="], + + "sst-win32-x86": ["sst-win32-x86@3.18.10", "", { "os": "win32", "cpu": "none" }, "sha512-pq8SmV0pIjBFMY6DraUZ4akyTxHnfjIKCRbBLdMxFUZK8TzA1NK2YdjRt1AwrgXRYGRyctrz/mt4WyO0SMOVQQ=="], + + "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], + + "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + + "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + + "strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="], + + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], + + "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tar": ["tar@7.5.11", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ=="], + + "tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="], + + "tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="], + + "tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="], + + "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + + "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], + + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + + "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], + + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], + + "tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + + "turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="], + + "turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="], + + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="], + + "turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="], + + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="], + + "turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="], + + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="], + + "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + + "typescript-eslint": ["typescript-eslint@8.57.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/parser": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA=="], + + "ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="], + + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], + + "unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="], + + "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + + "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@8.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "virtua": ["virtua@0.42.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-5FoAKcEvh05qsUF97Yz42SWJ7bwnPExjUYHGuoxz1EUtfWtaOgXaRwnylJbDpA0QcH1rKvJ2qsGRi9MK1fpQbg=="], + + "vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], + + "vite-plugin-icons-spritesheet": ["vite-plugin-icons-spritesheet@3.0.1", "", { "dependencies": { "chalk": "^5.4.1", "glob": "^11.0.1", "node-html-parser": "^7.0.1", "tinyexec": "^0.3.2" }, "peerDependencies": { "vite": ">=5.2.0" } }, "sha512-Cr0+Z6wRMwSwKisWW9PHeTjqmQFv0jwRQQMc3YgAhAgZEe03j21el0P/CA31KN/L5eiL1LhR14VTXl96LetonA=="], + + "vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="], + + "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + + "why-is-node-running": ["why-is-node-running@3.2.2", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], + + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@actions/artifact/@actions/core": ["@actions/core@2.0.3", "", { "dependencies": { "@actions/exec": "^2.0.0", "@actions/http-client": "^3.0.2" } }, "sha512-Od9Thc3T1mQJYddvVPM4QGiLUewdh+3txmDYHHxoNdkqysR1MbCT+rFOtNUxYAz+7+6RIsqipVahY2GJqGPyxA=="], + + "@actions/core/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + + "@actions/github/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + + "@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], + + "@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], + + "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="], + + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], + + "@ai-sdk/elevenlabs/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], + + "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AnGoxVNZ/E3EU4lW12rrufI6riqL2cEv4jk3OrjJ/i54XwR0CJU1V26jXAwxb+Pc+uZmYG++HM+gzXxPQZkMNQ=="], + + "@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], + + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + + "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-crypto/sha1-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/sha256-js/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.19", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-ini": "^3.972.18", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.18", "@aws-sdk/credential-provider-web-identity": "^3.972.18", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.993.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.8", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/credential-provider-env/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-provider-env/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/credential-provider-http/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-provider-http/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.8", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.8", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ozge/c7NdHUDyHqro6+P5oHt8wfKSUBN+olttiVfBe9Mw3wBMpPa3gQ0pZnG+gwBkKskBuip2bMR16tqYvUSEA=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-b6N9Nnlg8JInQwzBkUq5spNaXssM3h3zLxGzpPrnw0nHSIWPJPTbZzA5Ca285fcDUFuKP+qf3qkuqlAjGOdWhg=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-env": "3.932.0", "@aws-sdk/credential-provider-http": "3.932.0", "@aws-sdk/credential-provider-process": "3.932.0", "@aws-sdk/credential-provider-sso": "3.933.0", "@aws-sdk/credential-provider-web-identity": "3.933.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-HygGyKuMG5AaGXsmM0d81miWDon55xwalRHB3UmDg3QBhtunbNIoIaWUbNTKuBZXcIN6emeeEZw/YgSMqLc0YA=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-BodZYKvT4p/Dkm28Ql/FhDdS1+p51bcZeMMu2TRtU8PoMDHnVDhHz27zASEKSZwmhvquxHrZHB0IGuVqjZUtSQ=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.933.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.933.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/token-providers": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-/R1DBR7xNcuZIhS2RirU+P2o8E8/fOk+iLAhbqeSTq+g09fP/F6W7ouFpS5eVE2NIfWG7YBFoVddOhvuqpn51g=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-c7Eccw2lhFx2/+qJn3g+uIDWRuWi2A6Sz3PVvckFUEzPsP0dPUo19hlvtarwP5GzrsXn0yEPRVhpewsIaSCGaQ=="], + + "@aws-sdk/credential-provider-process/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-provider-process/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.8", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.8", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/credential-providers/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-providers/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.19", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-ini": "^3.972.18", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.18", "@aws-sdk/credential-provider-web-identity": "^3.972.18", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ=="], + + "@aws-sdk/credential-providers/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/nested-clients/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/nested-clients/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/nested-clients/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/nested-clients/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="], + + "@aws-sdk/nested-clients/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/nested-clients/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.993.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw=="], + + "@aws-sdk/nested-clients/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/nested-clients/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw=="], + + "@aws-sdk/token-providers/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.8", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA=="], + + "@aws-sdk/token-providers/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], + + "@azure/core-http/@azure/abort-controller": ["@azure/abort-controller@1.1.0", "", { "dependencies": { "tslib": "^2.2.0" } }, "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw=="], + + "@azure/core-http/@azure/core-tracing": ["@azure/core-tracing@1.0.0-preview.13", "", { "dependencies": { "@opentelemetry/api": "^1.0.1", "tslib": "^2.2.0" } }, "sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ=="], + + "@azure/core-http/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "@azure/core-http/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/config-array/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "@fastify/ajv-compiler/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + + "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + + "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "@octokit/core/@octokit/graphql": ["@octokit/graphql@7.1.1", "", { "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" } }, "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g=="], + + "@octokit/core/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@octokit/core/universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="], + + "@octokit/endpoint/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@octokit/endpoint/universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="], + + "@octokit/graphql/@octokit/request": ["@octokit/request@10.0.8", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw=="], + + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + + "@octokit/plugin-retry/@octokit/types": ["@octokit/types@6.41.0", "", { "dependencies": { "@octokit/openapi-types": "^12.11.0" } }, "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg=="], + + "@octokit/request/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@octokit/request/universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="], + + "@octokit/request-error/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@octokit/rest/@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/rest/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@13.2.1", "", { "dependencies": { "@octokit/types": "^15.0.1" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-Tj4PkZyIL6eBMYcG/76QGsedF0+dWVeLhYprTmuFVVxzDW7PQh23tM0TP0z+1MvSkxB29YFZwnUX+cXfTiSdyw=="], + + "@octokit/rest/@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/rest/@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@16.1.1", "", { "dependencies": { "@octokit/types": "^15.0.1" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-VztDkhM0ketQYSh5Im3IcKWFZl7VIrrsCaHbDINkdYeiiAsJzjhS2xRFCSJgfN6VOcsoW4laMtsmf3HcNqIimg=="], + + "@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="], + + "@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + + "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + + "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], + + "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + + "@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="], + + "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + + "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], + + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + + "@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + + "@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + + "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], + + "ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="], + + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "aws-sdk/events": ["events@1.1.1", "", {}, "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw=="], + + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + + "bl/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "eslint/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "eslint-plugin-import-x/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "fast-json-stringify/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "framer-motion/motion-dom": ["motion-dom@12.35.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg=="], + + "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "light-my-request/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], + + "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + + "nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], + + "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], + + "opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="], + + "opencontrol/zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], + + "opencontrol/zod-to-json-schema": ["zod-to-json-schema@3.24.3", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A=="], + + "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + + "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + + "shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + + "shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "tree-sitter-bash/node-addon-api": ["node-addon-api@8.6.0", "", {}, "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q=="], + + "url/punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="], + + "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "xml2js/sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], + + "zod-to-json-schema/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@actions/artifact/@actions/core/@actions/exec": ["@actions/exec@2.0.0", "", { "dependencies": { "@actions/io": "^2.0.0" } }, "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw=="], + + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw=="], + + "@aws-sdk/credential-provider-env/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/credential-provider-http/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Qzq7zj9yXUgAAJEbbmqRhm0jmUndl8nHG0AbxFEfCfQRVZWL96Qzx0mf8lYwT9hIMrXncLwy31HOthmbXwFRwQ=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw=="], + + "@aws-sdk/credential-provider-process/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw=="], + + "@aws-sdk/credential-providers/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/token-providers/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw=="], + + "@azure/core-http/xml2js/sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], + + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@fastify/ajv-compiler/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@hey-api/json-schema-ref-parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/graphql/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "@octokit/graphql/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/graphql/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], + + "@octokit/plugin-retry/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@12.11.0", "", {}, "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ=="], + + "@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/rest/@octokit/core/@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/rest/@octokit/core/@octokit/request": ["@octokit/request@10.0.8", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw=="], + + "@octokit/rest/@octokit/core/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/rest/@octokit/core/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + + "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + + "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "archiver-utils/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "archiver-utils/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "fast-json-stringify/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="], + + "opencontrol/@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "parse-bmfont-xml/xml2js/sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], + + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + + "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "vite-plugin-icons-spritesheet/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], + + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@aws-sdk/client-cognito-identity/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws-sdk/credential-provider-env/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/credential-provider-http/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/credential-provider-ini/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/credential-provider-login/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw=="], + + "@aws-sdk/credential-provider-process/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/credential-provider-web-identity/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/credential-providers/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@aws-sdk/token-providers/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "@octokit/graphql/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "babel-plugin-module-resolver/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "babel-plugin-module-resolver/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], + + "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + + "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@aws-sdk/credential-provider-cognito-identity/@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "archiver-utils/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "archiver-utils/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "archiver-utils/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + } +} diff --git a/Agent-backend/bunfig.toml b/Agent-backend/bunfig.toml new file mode 100644 index 000000000..36a21d933 --- /dev/null +++ b/Agent-backend/bunfig.toml @@ -0,0 +1,6 @@ +[install] +exact = true + +[test] +root = "./do-not-run-tests-from-root" + diff --git a/Agent-backend/docs/CLAUDE.md b/Agent-backend/docs/CLAUDE.md new file mode 100644 index 000000000..8e4878abc --- /dev/null +++ b/Agent-backend/docs/CLAUDE.md @@ -0,0 +1,26 @@ +# docs + +> **Path:** `Agent-backend/docs/` + +## Purpose + +Internal documentation and planning documents. Contains implementation plans and architecture decisions. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Planning documents may contain architecture decisions relevant to Orbit's integration | +| Orbit CLI | `reference` | Same | + +## Directories + +| Directory | Purpose | +| ------------ | ------------------------- | +| `plans/` | Implementation plans | +| `plans/git/` | Git-related feature plans | + +## Notes + +- **Separate from `packages/docs/`** — this `docs/` directory contains internal dev documentation, while `packages/docs/` is the public Mintlify documentation site. +- **`docs/plans/OPENCODE-MIGRATION-PLAN.md`** exists in the git status as untracked — likely the Orbit migration plan. diff --git a/Agent-backend/docs/MASTER-PROMPT.md b/Agent-backend/docs/MASTER-PROMPT.md new file mode 100644 index 000000000..702e43dca --- /dev/null +++ b/Agent-backend/docs/MASTER-PROMPT.md @@ -0,0 +1,264 @@ +# Master Prompt: Agent-backend Folder-by-Folder Review & CLAUDE.md Generation + +> **How to use:** Copy this entire file and paste it into a new Claude Code session opened at `/Users/no9labs/Developer/Recursive/Snowflake-v0/Agent-backend/`. The session will walk through every folder and subfolder, creating a `CLAUDE.md` file for each one. + +--- + +## Who You Are + +You are helping build **Orbit** — an AI-powered code editor. This `Agent-backend/` directory is a fork of [opencode](https://github.com/anomalyco/opencode), an open-source AI coding assistant. We are rebranding it and using it to build **two products**: + +### Product 1: Orbit Desktop App (existing — `/Users/no9labs/Developer/Recursive/Snowflake-v0/`) + +- **Stack:** Tauri 2 (Rust backend) + React 19 (TypeScript frontend) +- **Current AI backend:** `agent-bridge/` — a sidecar that wraps the Claude Agent SDK, communicates via stdin/stdout JSON pipe to Rust +- **Migration goal:** Replace `agent-bridge` with this `Agent-backend` running as an **HTTP + SSE server** (SDK format). The React frontend will call `fetch()` directly to the server. Rust only spawns the sidecar process. +- **What stays:** All existing UI components, stores, hooks, Rust crates (file system, git, terminal, browser, search, LSP, settings). Only the AI layer changes. + +### Product 2: Orbit CLI (new — built from this `Agent-backend/`) + +- **What it is:** A rebranded version of opencode's CLI/TUI, shipped as a standalone terminal-based AI coding tool +- **Stack:** Bun + TypeScript, standalone binary via `bun build --compile` +- **Approach:** Rebuild the CLI UI (TUI), keep the core engine, ship as "Orbit CLI" + +### The SDK Format Concept + +The `Agent-backend` serves as the **shared engine** for both products: + +``` +Agent-backend (this repo) +├── packages/opencode/src/server/ ← HTTP server (SDK for desktop app) +├── packages/opencode/src/session/ ← AI session engine (shared core) +├── packages/opencode/src/tool/ ← Tool execution (shared core) +├── packages/opencode/src/provider/ ← LLM providers (shared core) +├── packages/opencode/src/mcp/ ← MCP client (shared core) +├── packages/app/ ← Their SolidJS UI (reference for Orbit CLI TUI) +├── packages/tui/ ← Their terminal UI (reference/rebuild for Orbit CLI) +└── packages/desktop/ ← Their Tauri shell (reference for our desktop integration) +``` + +**The development workflow:** + +1. Developer works in `Agent-backend/` +2. Runs `bun run dev` to test features in CLI mode +3. Once working in CLI, build the binary for the desktop app +4. Wire up the new endpoint in the Snowflake React frontend + +**The SDK consumption pattern:** + +``` +Developer: "I added POST /session/:id/my-feature to the server" +Frontend: "Got it — I'll call fetch('/session/:id/my-feature') from React" +``` + +The desktop app (Snowflake-v0) consumes Agent-backend purely via HTTP. No shared imports, no monorepo workspace linking. The binary is compiled, copied to `src-tauri/binaries/`, and spawned as a Tauri sidecar. + +--- + +## Your Task + +**Go through every folder and subfolder in this `Agent-backend/` directory and create a `CLAUDE.md` file for each one.** + +### How This Works + +1. I will give you a folder path (e.g., `packages/opencode/src/tool/`) +2. You read every file in that folder (not subfolders — just the immediate files) +3. You create a `CLAUDE.md` in that folder that documents: + - **What this folder does** (2-3 sentences) + - **Key files** (table: filename, purpose, one-liner description) + - **Usage status** for both products (see template below) + - **Dependencies** (what this folder imports from / is imported by) + - **How to modify** (brief guide for adding/changing features here) +4. You tell me what you found and if anything needs attention +5. I give you the next folder + +### CLAUDE.md Template + +Use this template for every folder. Adapt the content, but keep the structure consistent: + +```markdown +# [Folder Name] + +> **Path:** `Agent-backend/[full/path/from/root]` + +## Purpose + +[2-3 sentences explaining what this folder does] + +## Usage Status + +| Product | Status | Notes | +| ------------------- | --------------------------------- | ------------ | +| Orbit Desktop (SDK) | `active` / `planned` / `not used` | [Brief note] | +| Orbit CLI | `active` / `planned` / `not used` | [Brief note] | + +## Key Files + +| File | Purpose | +| ------------- | -------------------- | +| `filename.ts` | One-line description | + +## Dependencies + +- **Imports from:** `../other-module`, `../another` +- **Imported by:** `../consumer`, `../../parent` + +## Development Guide + +[1-3 sentences on how to add/modify features in this folder] + +## Notes + +[Any important caveats, gotchas, or future plans. Remove this section if empty.] +``` + +### Usage Status Definitions + +| Status | Meaning | +| ----------- | ------------------------------------------------------------------------------ | +| `active` | Currently used or will be used in the migration | +| `planned` | Not used yet, but we plan to use it (e.g., enterprise, slack) | +| `not used` | Not applicable to this product, but kept for reference or potential future use | +| `rebuild` | We'll rebuild/rebrand this for our product (e.g., TUI, CLI UI) | +| `reference` | Kept as reference implementation, not used directly | + +--- + +## Context: What Agent-backend Replaces + +### Current Architecture (agent-bridge — being replaced) + +``` +React Frontend + → invoke('agent_*') ← 40+ Tauri commands + → Rust Backend + → stdin/stdout JSON pipe ← proprietary protocol (~1000 lines Rust + ~1000 lines TS) + → agent-bridge (Bun sidecar) + → Claude Agent SDK ← Claude-only, no other providers +``` + +**Problems with agent-bridge:** + +- Claude-only (no other LLM providers) +- Proprietary stdin/stdout protocol duplicated in Rust AND TypeScript +- ~2500 lines of Rust bridge/protocol code that's pure overhead +- SDK type workarounds (LocalSDKMessage mirror types) +- Blocking IPC (one request at a time) + +### Target Architecture (Agent-backend — the replacement) + +``` +React Frontend + → fetch() + EventSource ← standard HTTP + SSE, direct from browser + → Agent-backend HTTP server (Bun) + → Vercel AI SDK ← 17+ providers (Anthropic, OpenAI, Google, etc.) + +Rust Backend (minimal involvement): + → Spawns Agent-backend process on app startup + → Returns URL + password to frontend (one Tauri command) + → Handles non-AI features: file system, git, terminal, browser, search, LSP, settings +``` + +**What this gains:** + +- 17+ LLM providers out of the box +- Standard HTTP API (debuggable with curl, browser DevTools) +- No protocol duplication (Rust agent bridge code deleted entirely) +- SQLite persistence (replaces hand-rolled JSONL conversation system) +- Native MCP support with OAuth +- Active open-source community for bug fixes and new features + +### Feature Mapping (what maps to what) + +| Current (agent-bridge) | Agent-backend (opencode) | Notes | +| -------------------------- | ----------------------------------- | ------------------------------------- | +| `agent_create_session` | `POST /session` | Direct mapping | +| `agent_send_message` | `POST /session/:id/prompt_async` | Fire-and-forget, SSE events | +| `agent_interrupt` | `POST /session/:id/abort` | Direct mapping | +| `agent_respond_permission` | `POST /permission/:id/reply` | `once`/`always`/`reject` | +| `agent_set_model` | Per-message `PromptInput.model` | Model set per-prompt, not per-session | +| `agent_fork_session_at` | `POST /session/:id/fork` | Data copy, creates new session | +| `agent_rewind_files` | `POST /session/:id/revert` | Git-snapshot-based file restore | +| `agent_generate_title` | **Automatic** — built-in | `session.updated` SSE event | +| `agent_list_agents` | `GET /agent` | Direct mapping | +| `agent_list_commands` | `GET /command` | Direct mapping | +| `agent_list_skills` | `GET /skill` | Direct mapping | +| `conversation_list` | `GET /session` | Sessions from SQLite | +| `conversation_load` | `GET /session/:id/message` | Messages from SQLite | +| Streaming text | `message.part.delta` SSE event | Field: `text` | +| Streaming thinking | `message.part.delta` SSE event | Field: `reasoning` | +| Tool start/end | `message.part.updated` SSE event | `ToolPart` with state | +| Permission request | `permission.asked` SSE event | Direct mapping | +| Turn complete | `session.status` → `idle` SSE event | Direct mapping | + +### Custom Endpoints We Need to Add + +These don't exist in opencode and need to be built in our fork: + +| Endpoint | Purpose | Priority | +| -------------------------- | ----------------------------------------------- | -------- | +| `POST /agent` | Create agent definition | High | +| `PUT /agent/:name` | Update agent definition | High | +| `DELETE /agent/:name` | Delete agent definition | High | +| `POST /command` | Create slash command | High | +| `PUT /command/:name` | Update slash command | High | +| `DELETE /command/:name` | Delete slash command | High | +| `POST /agent/generate` | AI-generate agent definition from description | Medium | +| `POST /command/generate` | AI-generate command definition from description | Medium | +| `POST /enhance-bug-report` | AI-enhance bug report with context | Low | +| Browser tool bridge | Custom tool for WKWebView integration | Medium | + +--- + +## Context: The Parent Repository (Snowflake-v0) + +The `Agent-backend/` folder lives inside the Snowflake-v0 monorepo: + +``` +/Users/no9labs/Developer/Recursive/Snowflake-v0/ +├── apps/agent/ ← React frontend (main app) +├── apps/Canvas-UI-Builder/ ← Canvas UI Builder +├── apps/editor/ ← Editor app (stub) +├── agent-bridge/ ← OLD AI sidecar (being replaced by Agent-backend) +├── Agent-backend/ ← THIS — opencode fork, the new AI engine +├── src-tauri/ ← Rust backend (Tauri) +├── crates/ ← Rust library crates +├── packages/shared-schemas/ ← Zod schemas shared across apps +├── docs/ ← Documentation +└── CLAUDE.md ← Root project guidance +``` + +**Important:** Agent-backend is NOT in the Bun workspaces array. It's an independent codebase that happens to live inside Snowflake-v0. It has its own `package.json`, `tsconfig.json`, `turbo.json`, linting rules, CI/CD workflows, and git hooks. Run `bun install` inside `Agent-backend/` separately. + +--- + +## Context: Upstream Sync Strategy + +This is a fork of `anomalyco/opencode`. We will selectively sync upstream releases: + +- **Always grab:** Security fixes, new provider adapters, tool improvements, MCP updates +- **Review carefully:** Server route changes, SSE event format changes, session data model changes +- **Skip:** Their app UI changes (we have our own React UI), their desktop changes (we have our own Tauri app) + +Full upstream sync guide: `Agent-backend/docs/plans/git/upstream-sync.md` + +--- + +## Rules for Creating CLAUDE.md Files + +1. **Read before writing.** Read every file in the folder before creating the CLAUDE.md. Understand what the code does. +2. **Be accurate.** If you're unsure about a file's purpose, say so. Don't guess. +3. **Be concise.** The CLAUDE.md is a quick reference, not documentation. 2-3 sentences per section max. +4. **Use the template.** Every CLAUDE.md follows the same structure for consistency. +5. **Mark usage status honestly.** If something is `not used` but we're keeping it, say that. If it's `planned`, say that. +6. **Note cross-product relevance.** Some modules are shared core (used by both products), some are product-specific. +7. **Don't modify any source code.** Only create CLAUDE.md files. Do not edit, rename, or delete any existing files. +8. **Include the folder path.** Every CLAUDE.md starts with the full path from Agent-backend root. + +--- + +## Let's Begin + +Start by reading the root `Agent-backend/` directory listing. Then I'll give you the first folder path to review. We'll go breadth-first: root level first, then into packages, then into sub-packages, then into src directories. + +**Read the root directory and tell me what you see. Then I'll give you the first folder.** diff --git a/Agent-backend/docs/ai-sdk-updates.md b/Agent-backend/docs/ai-sdk-updates.md new file mode 100644 index 000000000..585c8090e --- /dev/null +++ b/Agent-backend/docs/ai-sdk-updates.md @@ -0,0 +1,302 @@ +# AI SDK Dependency Upgrade Report + +> Generated: March 9, 2026 +> Scope: Minor and patch upgrades only (no major version bumps) +> Source: `packages/opencode/package.json` + root `package.json` catalog + +--- + +## Summary + +- **23 AI SDK-related dependencies** audited +- **20 have available upgrades** (minor/patch) +- **3 are already at latest** within their major version + +### Priority Upgrades + +| Priority | Package | Reason | +| -------- | ----------------- | ----------------------------------------------------------- | +| HIGH | `ai` | 3 security fixes (SSRF prevention, memory growth) | +| HIGH | `@ai-sdk/openai` | Code interpreter, web_search tool, image gen tool | +| HIGH | `@ai-sdk/xai` | Video support, logprobs, new model IDs, image improvements | +| MEDIUM | `@ai-sdk/gateway` | Image model support, new model IDs, BYOK, auth improvements | +| LOW | All others | Dependency bumps only | + +--- + +## Detailed Upgrade Table + +| Package | Current | Latest (same major) | Delta | Risk | +| ----------------------------- | ------- | ------------------- | ----- | ------- | +| `ai` | 5.0.124 | **5.0.151** | +27 | Low | +| `@ai-sdk/anthropic` | 2.0.65 | **2.0.70** | +5 | Low | +| `@ai-sdk/openai` | 2.0.89 | **2.0.98** | +9 | Low | +| `@ai-sdk/amazon-bedrock` | 3.0.82 | **3.0.87** | +5 | Low | +| `@ai-sdk/azure` | 2.0.91 | **2.0.100** | +9 | Low | +| `@ai-sdk/cerebras` | 1.0.36 | **1.0.39** | +3 | Low | +| `@ai-sdk/cohere` | 2.0.22 | **2.0.24** | +2 | Low | +| `@ai-sdk/deepinfra` | 1.0.36 | **1.0.37** | +1 | Low | +| `@ai-sdk/gateway` | 2.0.30 | **2.0.56** | +26 | Low-Med | +| `@ai-sdk/google` | 2.0.54 | **2.0.60** | +6 | Low | +| `@ai-sdk/google-vertex` | 3.0.106 | **3.0.116** | +10 | Low | +| `@ai-sdk/groq` | 2.0.34 | **2.0.36** | +2 | Low | +| `@ai-sdk/mistral` | 2.0.27 | **2.0.29** | +2 | Low | +| `@ai-sdk/openai-compatible` | 1.0.32 | **1.0.34** | +2 | Low | +| `@ai-sdk/perplexity` | 2.0.23 | **2.0.25** | +2 | Low | +| `@ai-sdk/provider` | 2.0.1 | 2.0.1 | 0 | N/A | +| `@ai-sdk/provider-utils` | 3.0.21 | **3.0.22** | +1 | Low | +| `@ai-sdk/togetherai` | 1.0.34 | **1.0.37** | +3 | Low | +| `@ai-sdk/vercel` | 1.0.33 | **1.0.35** | +2 | Low | +| `@ai-sdk/xai` | 2.0.51 | **2.0.62** | +11 | Low | +| `@openrouter/ai-sdk-provider` | 1.5.4 | 1.5.4 | 0 | N/A | +| `@gitlab/gitlab-ai-provider` | 3.6.0 | **3.6.1** | +1 | Low | +| `ai-gateway-provider` | 2.3.1 | 2.3.1 | 0 | N/A | + +--- + +## Per-Package Changelog Details + +### `ai` (5.0.124 -> 5.0.151) + +The core Vercel AI SDK package. 27 patch releases behind. + +**Notable changes:** + +- **5.0.130** - Security: prevent unbounded memory growth in download functions. `download()` and `downloadBlob()` now enforce a default 2 GiB size limit. Downloads exceeding the limit are aborted with `DownloadError`. Added `createDownload({ maxBytes })` factory and `download` option on `transcribe()` / `experimental_generateVideo()`. +- **5.0.146** - Security: SSRF prevention in downloads. Added URL validation to `download` to reject private/internal IP addresses, localhost, and non-HTTP protocols before fetching. +- **5.0.149** - Security: SSRF redirect bypass fix. `download` now validates the final URL after following HTTP redirects, preventing SSRF bypass via open redirects to internal addresses. +- All other versions: transitive dependency updates to `@ai-sdk/gateway` and `@ai-sdk/provider-utils`. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/ai/CHANGELOG.md +> npm: https://www.npmjs.com/package/ai + +--- + +### `@ai-sdk/openai` (2.0.89 -> 2.0.98) + +9 patch releases with significant feature additions. + +| Version | Change | +| ------- | --------------------------------------------------------------------------------------------------- | +| 2.0.90 | Rework file search tool; remove provider-executed tools from chat completions model | +| 2.0.91 | Add `maxToolCalls` provider option; image generation tool support | +| 2.0.92 | Dependency update | +| 2.0.93 | Code interpreter tool calls and results | +| 2.0.94 | Fix: send sources action as include; fix code interpreter tool in `doGenerate` | +| 2.0.95 | Add JSDoc for openai tools | +| 2.0.96 | Fix: only send tool calls finish reason for non-provider-executed tools; add `web_search` tool | +| 2.0.97 | Fix: timestamp granularities support for openai transcription | +| 2.0.98 | Fix: include filename & fileExtension to avoid `experimental_transcribe` failures with valid Buffer | + +> Changelog: https://github.com/vercel/ai/blob/main/packages/openai/CHANGELOG.md + +--- + +### `@ai-sdk/xai` (2.0.51 -> 2.0.62) + +11 patch releases with notable new features. + +| Version | Change | +| ------- | --------------------------------------------------------------------------- | +| 2.0.52 | Add dedicated XaiImageModel with JSON-based image editing | +| 2.0.53 | Normalize provider-specific model options type names, ensure exports | +| 2.0.54 | Add Responses API `file_search` streaming support | +| 2.0.56 | Fix: handle inconsistent cached token reporting | +| 2.0.57 | Add video support | +| 2.0.58 | Add `grok-imagine-image-pro` and `grok-2-image-1212` model IDs | +| 2.0.59 | Add `resolution` provider option (`"1k"` or `"2k"`) for image models | +| 2.0.61 | Add `logprobs` and `topLogprobs` support in chat/responses provider options | +| 2.0.62 | Remove obsolete model IDs for Anthropic, Google, OpenAI, xAI | +| Others | Dependency bumps | + +> Changelog: https://github.com/vercel/ai/blob/main/packages/xai/CHANGELOG.md + +--- + +### `@ai-sdk/gateway` (2.0.30 -> 2.0.56) + +26 patch releases -- the largest delta. Significant feature additions. + +**Key changes:** + +- Image model support (server-side image request splitting, per-provider `maxImagesPerCall`, image editing) +- New model IDs: GPT-5 pro, GPT-5-codex, GPT-5.2, Gemini 3 Flash, Sonnet 4.5, DeepSeek V3.1/V3.2, Qwen models, Imagen 4 Ultra, and more +- Provider options: `models` for model routing, `user` and `tags`, `only` option, zero data retention, request-scoped BYOK +- Auth improvements: better error messages, OIDC refresh with `@vercel/oidc` +- React Native fix: added `react-native` export condition +- Lazy schema loading for performance +- Expose raw finish reason +- Provider version header + +> **Note:** Some sub-dependency bumps within this range update `@ai-sdk/provider` to 3.x and `@ai-sdk/provider-utils` to 4.x internally. Since you're already on 2.0.30, moving to 2.0.56 should be compatible, but verify peer dependencies align. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/gateway/CHANGELOG.md + +--- + +### `@ai-sdk/anthropic` (2.0.65 -> 2.0.70) + +5 patch releases. All are dependency bumps to `@ai-sdk/provider-utils`. No Anthropic-specific code changes. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/anthropic/CHANGELOG.md + +--- + +### `@ai-sdk/amazon-bedrock` (3.0.82 -> 3.0.87) + +5 patch releases. All dependency bumps. The `ai-v5` dist-tag points to `3.0.87`. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/amazon-bedrock/CHANGELOG.md + +--- + +### `@ai-sdk/azure` (2.0.91 -> 2.0.100) + +9 patch releases. All dependency bumps to `@ai-sdk/openai`, `@ai-sdk/provider-utils`, and `@ai-sdk/provider`. No Azure-specific changes. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/azure/CHANGELOG.md + +--- + +### `@ai-sdk/cerebras` (1.0.36 -> 1.0.39) + +3 patch releases. All dependency bumps (`@ai-sdk/openai-compatible`, `@ai-sdk/provider-utils`). No Cerebras-specific changes. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/cerebras/CHANGELOG.md + +--- + +### `@ai-sdk/cohere` (2.0.22 -> 2.0.24) + +2 patch releases. Both are dependency bumps to `@ai-sdk/provider-utils`. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/cohere/CHANGELOG.md + +--- + +### `@ai-sdk/deepinfra` (1.0.36 -> 1.0.37) + +1 patch release. Transitive dependency bump only. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/deepinfra/CHANGELOG.md + +--- + +### `@ai-sdk/google` (2.0.54 -> 2.0.60) + +6 patch releases. All dependency bumps to `@ai-sdk/provider-utils` and `@ai-sdk/provider`. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/google/CHANGELOG.md + +--- + +### `@ai-sdk/google-vertex` (3.0.106 -> 3.0.116) + +10 patch releases. All dependency bumps: + +- `@ai-sdk/google`: 2.0.54 -> 2.0.60 +- `@ai-sdk/anthropic`: 2.0.65 -> 2.0.70 +- `@ai-sdk/provider-utils`: 3.0.21 -> 3.0.22 + +> Changelog: https://github.com/vercel/ai/blob/main/packages/google-vertex/CHANGELOG.md + +--- + +### `@ai-sdk/groq` (2.0.34 -> 2.0.36) + +| Version | Change | +| ------- | -------------------------------------------- | +| 2.0.35 | Dependency update (`@ai-sdk/provider-utils`) | +| 2.0.36 | Remove obsolete `saba` model ID | + +> Changelog: https://github.com/vercel/ai/blob/main/packages/groq/CHANGELOG.md + +--- + +### `@ai-sdk/mistral` (2.0.27 -> 2.0.29) + +2 patch releases. Both dependency bumps to `@ai-sdk/provider-utils`. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/mistral/CHANGELOG.md + +--- + +### `@ai-sdk/openai-compatible` (1.0.32 -> 1.0.34) + +2 patch releases. Dependency bumps to `@ai-sdk/provider-utils`. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/openai-compatible/CHANGELOG.md + +--- + +### `@ai-sdk/perplexity` (2.0.23 -> 2.0.25) + +2 patch releases. Both dependency bumps to `@ai-sdk/provider-utils`. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/perplexity/CHANGELOG.md + +--- + +### `@ai-sdk/provider` (2.0.1 -- already at latest) + +No upgrade available within major 2.x. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/provider/CHANGELOG.md + +--- + +### `@ai-sdk/provider-utils` (3.0.21 -> 3.0.22) + +1 patch release. Transitive dependency bump. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/provider-utils/CHANGELOG.md + +--- + +### `@ai-sdk/togetherai` (1.0.34 -> 1.0.37) + +3 patch releases. All dependency bumps (`@ai-sdk/openai-compatible`, `@ai-sdk/provider-utils`). + +> Changelog: https://github.com/vercel/ai/blob/main/packages/togetherai/CHANGELOG.md + +--- + +### `@ai-sdk/vercel` (1.0.33 -> 1.0.35) + +2 patch releases. Both dependency bumps to `@ai-sdk/provider-utils`. + +> Changelog: https://github.com/vercel/ai/blob/main/packages/vercel/CHANGELOG.md + +--- + +### `@openrouter/ai-sdk-provider` (1.5.4 -- already at latest) + +No upgrade available within major 1.x. Note: v2.x exists (up to 2.2.5) but that's a major bump. + +> Note: This package has a patch applied in `patchedDependencies`. +> Releases: https://github.com/OpenRouterTeam/ai-sdk-provider/releases + +--- + +### `@gitlab/gitlab-ai-provider` (3.6.0 -> 3.6.1) + +1 patch release available. + +> Releases: https://gitlab.com/gitlab-org/editor-extensions/gitlab-ai-provider/-/releases + +--- + +### `ai-gateway-provider` (2.3.1 -- already at latest) + +No upgrade available within major 2.x. Note: v3.x exists (up to 3.1.1) but that's a major bump. + +> Releases: https://github.com/cloudflare/ai/releases + +--- + +## Notes + +- The `ai` catalog version in the root `package.json` is `5.0.124`. Upgrading `ai` requires updating the catalog entry. +- All `@ai-sdk/*` packages are pinned in `packages/opencode/package.json` directly. +- `@openrouter/ai-sdk-provider@1.5.4` has a patch in `patchedDependencies` -- verify the patch still applies if upgrading to a future version. +- Major version upgrades (AI SDK v6/v7) are available for most packages but are out of scope for this report. diff --git a/Agent-backend/docs/plans/CLAUDE.md b/Agent-backend/docs/plans/CLAUDE.md new file mode 100644 index 000000000..eddf007e9 --- /dev/null +++ b/Agent-backend/docs/plans/CLAUDE.md @@ -0,0 +1,28 @@ +# plans + +> **Path:** `Agent-backend/docs/plans/` + +## Purpose + +Implementation plans and design documents for the Orbit fork. Contains detailed step-by-step plans for major initiatives like the OpenCode-to-Orbit rebrand and git upstream synchronization strategy. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | -------- | -------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `active` | Contains the rebrand plan that guides all user-facing string changes | +| Orbit CLI | `active` | Same | + +## Files + +| File | Purpose | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `orbit-rebrand.md` | Comprehensive rebrand plan — surgical string replacements across ~40 files, rules for what to rename vs. keep for upstream compatibility | +| `STRIP-TO-SDK-AUDIT.md` | Strip-to-SDK audit — what was removed, kept, kept as reference. **Status: COMPLETE** | +| `STRICTNESS-UPGRADE-PLAN.md` | 7-phase plan to bring code quality up to Orbit's strictness level (ESLint, `noUncheckedIndexedAccess`, remove `any`, structured logging, pre-commit hooks) | + +## Subdirectories + +| Directory | Purpose | +| --------- | ------------------------------------------ | +| `git/` | Git-related plans (upstream sync strategy) | diff --git a/Agent-backend/docs/plans/STRICTNESS-UPGRADE-PLAN.md b/Agent-backend/docs/plans/STRICTNESS-UPGRADE-PLAN.md new file mode 100644 index 000000000..018e951b7 --- /dev/null +++ b/Agent-backend/docs/plans/STRICTNESS-UPGRADE-PLAN.md @@ -0,0 +1,488 @@ +# Agent-Backend: Strictness Upgrade Plan + +## Goal + +Bring `Agent-backend/` up to the same code quality and strictness level as our primary repo (`Snowflake-v0/`). The primary repo enforces `tseslint.configs.strictTypeChecked` + `stylisticTypeChecked` with 20+ explicit rules, zero tolerance for `any`, and structured logging. This repo currently has **zero linting** — not even an ESLint config file exists. + +## Important Context + +- This is a **Bun monorepo** with Turbo orchestration — NOT React. +- The TUI uses **SolidJS + opentui** for terminal rendering; the engine is plain TypeScript. +- Type checking uses **`tsgo`** (Microsoft's Go port of `tsc`) — keep it, it's 10x faster and same rules as `tsc`. +- Auto-generated SDK files (`packages/sdk/js/src/gen/`, `packages/sdk/js/src/v2/gen/`) must be **excluded** from linting. +- Reference packages (`packages/app/`, `packages/desktop/`, `packages/ui/`) are **read-only** — do NOT lint or modify them. +- The CLI code (`packages/opencode/src/cli/`) legitimately uses `console.log` for user output — allow it there. +- Test files (`packages/opencode/test/`) should have relaxed `no-unsafe-*` rules (same as Orbit). + +## Current State (Verified Counts) + +### Violations + +| Pattern | Count | Files | Worst Offenders | +| ---------------------------------- | ------- | ----- | ------------------------------------------------------------------------------------------------------------------------------ | +| `: any` or `as any` (engine src) | **136** | 39 | `provider/provider.ts` (16), `util/log.ts` (11), `provider/transform.ts` (8), `plugin/copilot.ts` (8), `session/prompt.ts` (8) | +| `: any` or `as any` (all packages) | **215** | 50 | Includes tests (81 in `transform.test.ts` alone) and auto-generated SDK | +| `@ts-ignore` / `@ts-expect-error` | **18** | 12 | `provider/provider.ts` (4), `provider/models.ts` (2), `session/index.ts` (2), `plugin/index.ts` (2) | +| `console.log/warn/error` | **107** | 13 | `cli/cmd/stats.ts` (41), `cli/cmd/github.ts` (42) — these are CLI commands, expected | + +### What's Missing vs Orbit + +| Missing | Orbit Has | Impact | +| ------------------------------ | ---------------------------------------------------------------- | -------------------------------------------------------------------------- | +| **ESLint** (zero config) | `strictTypeChecked` + `stylisticTypeChecked` + 20 explicit rules | No static analysis for unsafe patterns | +| **`noUncheckedIndexedAccess`** | `true` (via `@tsconfig/bun`) | `false` override in tsconfig — `array[0]` returns `T` not `T \| undefined` | +| **Pre-commit hook** | Lint + format on commit | Only pre-push (typecheck only) | +| **Import ordering** | `import-x/order` with groups + alphabetize | No import organization | +| **Consistent type imports** | `consistent-type-imports` enforced | `import type {}` not enforced | +| **Explicit return types** | `explicit-function-return-type` on exports | No return type enforcement | +| **Strict boolean expressions** | `strict-boolean-expressions` | Implicit truthy checks allowed | +| **Exhaustive switches** | `switch-exhaustiveness-check` | Missing cases not caught | +| **No floating promises** | `no-floating-promises` | Unhandled promises not caught | +| **Structured logging** | `createLogger('Name')` required | `console.log` used directly (engine code) | +| **Dead code analysis** (Knip) | Configured and running | No unused file/export detection | + +### What EXISTS and Works + +| Tool | Status | +| ------------------------------------------- | ------------------------------------- | +| TypeScript (`tsgo`) with `strict: true` | Working — pre-push hook runs it | +| Prettier (`semi: false`, `printWidth: 120`) | Config exists but no enforcement hook | +| Bun test | Working from `packages/opencode/` | +| Turbo orchestration | Working for `typecheck` and `build` | + +### Junk in `packages/opencode/package.json` (Clean Up) + +These scripts are fake/broken and should be removed or replaced: + +```json +"random": "echo 'Random script updated at...'", // Junk +"lint": "echo 'Running lint checks...' && bun test --coverage", // FAKE — runs tests, not lint +"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts", // Broken syntax +"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;", // Useless +"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'" // Dead +``` + +Also remove: `"randomField": "this-is-a-random-value-12345"` (line 25). + +--- + +## Execution Plan + +### Phase 1: Infrastructure Setup (Non-Breaking) + +**Goal:** Install ESLint, create config, add scripts — but don't enforce yet. Everything should pass with the current codebase (all rules set to `warn` initially or excluded). + +#### Step 1.1: Clean up `packages/opencode/package.json` + +Remove these scripts: `random`, `lint` (the fake one), `format`, `docs`, `deploy`. +Remove: `"randomField": "this-is-a-random-value-12345"`. + +#### Step 1.2: Install ESLint dependencies + +From repo root: + +```bash +bun add -d eslint @eslint/js typescript-eslint eslint-plugin-import-x +``` + +**Do NOT install React/React Hooks plugins** — this is not a React codebase. The engine is plain TS, the TUI is SolidJS. + +#### Step 1.3: Create `eslint.config.ts` at repo root + +Model after Orbit's config but adapted for this codebase: + +```typescript +import eslint from "@eslint/js" +import { defineConfig } from "eslint/config" +import importPlugin from "eslint-plugin-import-x" +import tseslint from "typescript-eslint" + +export default defineConfig( + { + ignores: [ + "dist/", + "node_modules/", + "*.config.js", + "*.config.mjs", + // Reference packages — read-only, do not lint + "packages/app/", + "packages/desktop/", + "packages/ui/", + // Auto-generated SDK code + "packages/sdk/js/src/gen/", + "packages/sdk/js/src/v2/gen/", + // Migration files + "packages/opencode/migration/", + ], + }, + eslint.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + plugins: { + // @ts-expect-error - Plugin types have legacy config format incompatibility + "import-x": importPlugin, + }, + rules: { + /* TypeScript strict — MATCH ORBIT */ + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-call": "error", + "@typescript-eslint/no-unsafe-return": "error", + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/restrict-template-expressions": "error", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/no-misused-promises": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { prefer: "type-imports", fixStyle: "separate-type-imports" }, + ], + "@typescript-eslint/consistent-type-exports": "error", + "@typescript-eslint/explicit-function-return-type": [ + "error", + { allowExpressions: true, allowTypedFunctionExpressions: true }, + ], + "@typescript-eslint/explicit-module-boundary-types": "error", + "@typescript-eslint/no-unnecessary-condition": "error", + "@typescript-eslint/prefer-nullish-coalescing": "error", + "@typescript-eslint/strict-boolean-expressions": [ + "error", + { allowNullableBoolean: true, allowNullableString: true }, + ], + "@typescript-eslint/switch-exhaustiveness-check": "error", + "@typescript-eslint/no-non-null-assertion": "error", + + /* Imports */ + "import-x/order": [ + "error", + { + groups: ["builtin", "external", "internal", "parent", "sibling", "index", "type"], + "newlines-between": "always", + alphabetize: { order: "asc" }, + }, + ], + "import-x/no-duplicates": "error", + "import-x/consistent-type-specifier-style": ["error", "prefer-top-level"], + + /* General */ + "no-console": ["error", { allow: ["warn", "error"] }], + eqeqeq: ["error", "always"], + }, + }, + /* Test files — relaxed type checking */ + { + files: ["**/test/**/*.ts", "**/*.test.ts", "**/*.spec.ts"], + rules: { + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-explicit-any": "off", + }, + }, + /* CLI commands — console.log is expected output */ + { + files: ["packages/opencode/src/cli/**/*.ts", "packages/opencode/src/util/log.ts"], + rules: { + "no-console": "off", + }, + }, + /* TUI (SolidJS) — may need relaxed rules */ + { + files: ["packages/opencode/src/cli/cmd/tui/**/*.ts"], + rules: { + // SolidJS patterns may trigger some rules differently than expected + "@typescript-eslint/no-unnecessary-condition": "off", + }, + }, +) +``` + +#### Step 1.4: Add lint scripts + +**Root `package.json`:** + +```json +"scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix" +} +``` + +**`packages/opencode/package.json`** — replace the fake lint script: + +```json +"lint": "eslint src/" +``` + +#### Step 1.5: Verify ESLint runs + +```bash +bun run lint 2>&1 | head -100 +``` + +Expect hundreds of errors. This is the baseline. Count them. + +--- + +### Phase 2: TypeScript Strictness (Moderate Risk) + +**Goal:** Enable `noUncheckedIndexedAccess` and fix the resulting type errors. + +#### Step 2.1: Enable `noUncheckedIndexedAccess` + +In `packages/opencode/tsconfig.json`, change: + +```json +"noUncheckedIndexedAccess": true +``` + +#### Step 2.2: Run type checker and fix errors + +```bash +cd packages/opencode && bun run typecheck 2>&1 | head -200 +``` + +Every `array[index]` access will now return `T | undefined` instead of `T`. Fix patterns: + +```typescript +// Before (unsafe) +const item = array[0] +item.doSomething() + +// After (safe) +const item = array[0] +if (item) { + item.doSomething() +} + +// Or with nullish coalescing +const name = items[0]?.name ?? "default" +``` + +**Expected impact:** This will touch many files. It's the single highest-value strictness change. + +#### Step 2.3: Verify all tests still pass + +```bash +cd packages/opencode && bun test --timeout 30000 +``` + +--- + +### Phase 3: Fix `any` Usages (High Effort — File by File) + +**Goal:** Eliminate all 136 `any` usages in engine source code. + +#### Priority Order (by file severity) + +Work through files from worst to cleanest. For each file: + +1. Read the file to understand why `any` was used +2. Replace with proper types (generics, `unknown`, specific interfaces) +3. Run typecheck after each file +4. Run tests to verify no regressions + +| Priority | File | Count | Notes | +| -------- | --------------------------- | ----- | ---------------------------------------------- | +| 1 | `provider/provider.ts` | 16 | Provider registry — needs AI SDK type imports | +| 2 | `util/log.ts` | 11 | Logging utility — likely `unknown` or generics | +| 3 | `provider/transform.ts` | 8 | Response transforms — AI SDK types | +| 4 | `plugin/copilot.ts` | 8 | Copilot plugin — external API types | +| 5 | `session/prompt.ts` | 8 | Prompt building — message types | +| 6 | `storage/json-migration.ts` | 6 | Legacy migration — `unknown` + type guards | +| 7 | `installation/index.ts` | 5 | Installation detection | +| 8 | `lsp/index.ts` | 4 | LSP — `vscode-languageserver-types` | +| 9 | `bus/index.ts` | 3 | Event bus — generics | +| 10 | `lsp/server.ts` | 3 | LSP server | + +**Common replacement strategies:** + +| `any` Pattern | Replacement | +| ----------------------- | ----------------------------------------------- | +| `catch (e: any)` | `catch (e: unknown)` + type narrowing | +| `as any` to bypass type | Find the real type from the library | +| Function params `: any` | Use generics `` or `unknown` | +| `Record` | `Record` or specific interface | +| AI SDK callback params | Import types from `@ai-sdk/provider` | +| JSON parsed data | `unknown` + Zod validation or type guards | + +**Files that are likely AUTO-GENERATED and should be EXCLUDED, not fixed:** + +- `packages/sdk/js/src/gen/**` — auto-generated from OpenAPI spec +- `packages/sdk/js/src/v2/gen/**` — auto-generated v2 client + +--- + +### Phase 4: Fix `@ts-ignore` / `@ts-expect-error` (18 Occurrences) + +**Goal:** Remove all suppression comments by fixing underlying type issues. + +For each one: + +1. Read the suppressed line and the error it hides +2. Fix the type error properly +3. Remove the suppression comment +4. Run typecheck + +| File | Count | Likely Cause | +| ---------------------- | ----- | ---------------------- | +| `provider/provider.ts` | 4 | AI SDK type mismatches | +| `provider/models.ts` | 2 | Model definition types | +| `session/index.ts` | 2 | Session lifecycle | +| `plugin/index.ts` | 2 | Plugin loading | +| Others (8 files × 1) | 8 | Various | + +--- + +### Phase 5: Structured Logging (Engine Code Only) + +**Goal:** Replace `console.log` in engine source code with a structured logger. CLI commands keep `console.log`. + +#### Step 5.1: Audit `util/log.ts` + +The codebase already has a logging utility at `packages/opencode/src/util/log.ts`. Understand what it provides and whether it's sufficient as a structured logger. + +#### Step 5.2: Classify console usage + +The 107 `console.*` calls break down as: + +- **CLI commands** (~95): `cli/cmd/stats.ts` (41), `cli/cmd/github.ts` (42), others — these are **expected and should stay** (user-facing output) +- **Engine code** (~12): These should use structured logging + +Only fix engine-internal code. CLI commands that print to stdout keep `console.log`. + +#### Step 5.3: Replace engine console calls + +For non-CLI files that use `console.*`, switch to the structured logger from `util/log.ts`. + +--- + +### Phase 6: Pre-Commit Hook + CI Scripts + +**Goal:** Prevent regressions with hooks and scripts. + +#### Step 6.1: Add pre-commit hook + +Create `.husky/pre-commit`: + +```bash +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +# Lint staged files (fast — only changed files) +bunx lint-staged +``` + +Install lint-staged: + +```bash +bun add -d lint-staged +``` + +Add to root `package.json`: + +```json +"lint-staged": { + "*.ts": ["eslint --fix", "prettier --write"], + "*.tsx": ["eslint --fix", "prettier --write"] +} +``` + +#### Step 6.2: Update root `package.json` scripts + +```json +"scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "check": "bun turbo typecheck && bun run lint", + "ci": "bun turbo typecheck && bun run lint && cd packages/opencode && bun test --timeout 30000" +} +``` + +#### Step 6.3: Update pre-push hook + +The existing `.husky/pre-push` runs `bun typecheck`. Add lint: + +```bash +bun turbo typecheck && bun run lint +``` + +--- + +### Phase 7: Validation + +**Goal:** Verify everything works end to end. + +```bash +# 1. Type checking passes +bun turbo typecheck + +# 2. Linting passes with zero errors +bun run lint + +# 3. All tests pass +cd packages/opencode && bun test --timeout 30000 + +# 4. Build still works +cd packages/opencode && bun run build + +# 5. Pre-commit hook works +echo "test" >> /tmp/test-file && git add -A && git commit -m "test" --dry-run +``` + +--- + +## Rules for This Session + +1. **Work phase by phase.** Complete each phase before moving to the next. +2. **Run typecheck after every file change.** Don't accumulate errors. +3. **Run tests after each phase.** Catch regressions early. +4. **Do NOT modify reference packages** (`packages/app/`, `packages/desktop/`, `packages/ui/`). +5. **Do NOT modify auto-generated files** (`packages/sdk/js/src/gen/`, `packages/sdk/js/src/v2/gen/`). +6. **Keep `tsgo`** — do not switch to `tsc`. Same rules, faster. +7. **CLI commands keep `console.log`** — only engine code needs structured logging. +8. **Fix types properly** — don't just swap `any` for `unknown` blindly. Understand what type should actually be there. +9. **Preserve style conventions** — no semicolons, `printWidth: 120`, early returns, no destructuring, single-word names. +10. **Commit after each phase** — so we can revert if needed. + +## Target State + +When done, this repo should have: + +- [x] ESLint with `strictTypeChecked` + `stylisticTypeChecked` (matching Orbit) +- [x] Zero `any` in engine source code +- [x] Zero `@ts-ignore` / `@ts-expect-error` +- [x] `noUncheckedIndexedAccess: true` +- [x] Structured logging in engine code +- [x] Import ordering enforced +- [x] Consistent type imports enforced +- [x] Explicit return types on exports +- [x] Strict boolean expressions +- [x] Exhaustive switch statements +- [x] No floating promises +- [x] Pre-commit hook (lint-staged + prettier) +- [x] `check` and `ci` scripts +- [x] Clean `package.json` (no junk scripts) + +## Reference Files + +| File | Purpose | +| ----------------------------------------------- | ------------------------------------------------------- | +| `Snowflake-v0/eslint.config.ts` | **THE TARGET** — Orbit's ESLint config with all rules | +| `Agent-backend/CLAUDE.md` | Current code quality status and gap analysis | +| `Agent-backend/AGENTS.md` | Style conventions (advisory — we're adding enforcement) | +| `Agent-backend/packages/opencode/tsconfig.json` | TypeScript config to modify | +| `Agent-backend/packages/opencode/package.json` | Scripts to clean up | +| `Agent-backend/.husky/pre-push` | Existing hook to extend | diff --git a/Agent-backend/docs/plans/STRIP-TO-SDK-AUDIT.md b/Agent-backend/docs/plans/STRIP-TO-SDK-AUDIT.md new file mode 100644 index 000000000..741f4695e --- /dev/null +++ b/Agent-backend/docs/plans/STRIP-TO-SDK-AUDIT.md @@ -0,0 +1,187 @@ +# Agent-Backend: Strip-to-SDK Audit + +## What This Is + +We forked OpenCode (an open-source AI coding agent) to use as the engine for **Orbit** — our AI-powered code editor. This repo (`Agent-backend/`) is that fork. It contains the full OpenCode monorepo: the core engine, CLI, server, plus all of upstream's product surfaces (web app, desktop app, marketing site, billing console, etc.). + +We're walking through every package and directory to decide what stays and what goes — stripping it down to a clean SDK while preserving reference code we can learn from. + +## Instructions for Claude + +Walk through every package and major directory in this repo, one by one. For each one: + +1. **Read the folder** — read the CLAUDE.md, package.json, and key source files to understand what it actually does. +2. **Tell me what it does** — explain it in plain language. What is this? What does it power? Who uses it? +3. **Give your recommendation** — should we keep it, keep as reference, or remove it? And WHY. +4. **Ask me** — "Should we keep this or delete it?" Wait for my answer before moving on. +5. **If we keep it**, update its CLAUDE.md with: + - What it does and the recommendation + - Why we're keeping it + - A "When to Reference This" lookup table (if it's reference code) — maps "if you're building X" → "read this file" so future sessions don't need to read every file + - Key data patterns that are portable to React/Zustand + - An "Orbit Mapping" table showing what Orbit already has vs. what's still unbuilt + +## The Three Categories + +### KEEP (engine — actively used) + +Code that Orbit runs directly. The core engine, CLI, server, SDK client, tools, providers, plugins, sessions, MCP, permissions, config, storage. + +### KEEP AS REFERENCE (not run, but valuable for patterns) + +Upstream's desktop app and web app codebase. We won't run this SolidJS code, but it shows: + +- **How to fetch data** from the engine (SDK calls, SSE event handling) +- **How to wire up features** (model picker, settings, permissions, file tree, terminal) +- **State management patterns** (two-tier global/session state, optimistic updates, cache eviction) +- **Data shapes and API call patterns** we'll reimplement in React + +When keeping as reference, update the CLAUDE.md so it serves as a **lookup guide** — future sessions should be able to read just the CLAUDE.md and know exactly which file to open for what they're building, without reading all the source files. + +### REMOVE (not related to the product) + +Upstream infrastructure with no reference value: + +- Marketing/docs sites +- Admin/billing console (PlanetScale, Stripe) +- CI/Docker containers +- Email templates +- Brand assets (we have our own) +- Cloud deployment recipes (SST/Cloudflare) +- Nix packaging +- E2E tests for the SolidJS web app + +## What We Need (engine) + +- All 30+ built-in tools (bash, read, write, edit, grep, glob, websearch, etc.) +- All 20+ LLM providers (Anthropic, OpenAI, Google, Azure, Bedrock, Groq, etc.) +- The HTTP + SSE server (`serve` command) +- The TUI (terminal UI) +- The SDK client (TypeScript API client) +- MCP support (Model Context Protocol) +- Plugin system +- Session management, permissions, config, storage, multi-workspace — ALL of it + +## How to Walk Through + +Format each entry like this: + +--- + +### `packages/[name]/` + +**What it does:** [2-3 sentences based on reading it] + +**What depends on it:** [List packages that import from this one] + +**My recommendation:** KEEP (engine) / KEEP AS REFERENCE / REMOVE + +**Why:** [1-2 sentences] + +**Should we keep this or delete it?** + +--- + +Then wait for my response. If I say keep, update its CLAUDE.md and move on. If I say delete, remove it and move on. + +## After All Packages + +Give the final summary: + +- List of packages **DELETED** +- List of packages **KEPT** (engine) +- List of packages **KEPT AS REFERENCE** +- Updated `workspaces` array for package.json + +## Safety Rules + +- **ALWAYS read the actual code** before recommending. Don't guess from the name. +- **ALWAYS check if `packages/opencode/` imports from a package** before recommending removal. +- **When in doubt, recommend KEEP.** We can always remove more later. +- **Never recommend removing anything inside `packages/opencode/src/`.** That's the engine. All 40+ subdirectories stay. +- If a package is a dependency of something we're keeping, it stays — even if we don't use it directly. +- When removing, always chase the import chain — delete root config files (like `sst.config.ts`, `flake.nix`) that reference removed directories. + +## Progress + +### Removed + +| Directory | What it was | Why removed | +| ---------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------- | +| `infra/` + `sst.config.ts` | SST cloud deployment (Cloudflare, PlanetScale, Stripe) | Upstream SaaS infrastructure — Orbit runs locally | +| `nix/` + `flake.nix` | Nix packaging recipes | Orbit has its own build pipeline | +| `packages/app/e2e/` | Playwright E2E tests for SolidJS web app | Tests SolidJS DOM selectors, can't run against React | +| `packages/app/public/` | OpenCode branding (favicons, manifests, social images) | Orbit has its own branding | +| `packages/app/script/` | E2E test orchestrator | Dead after e2e/ removal | +| `packages/app/src/addons/` | Ghostty WASM terminal serialization | Orbit uses xterm.js, not Ghostty | +| `packages/app/src/i18n/` | 18 locale translation files (~800KB) | SolidJS-specific string keys, Orbit writes its own i18n | +| `packages/console/` | Admin/billing console (5 sub-packages: core, app, function, mail, resource) | PlanetScale + Stripe billing — upstream SaaS | +| `packages/containers/` | CI Docker images | Upstream CI infrastructure | +| `packages/desktop-electron/` | Electron alternative desktop app | Orbit has its own Tauri app | +| `packages/docs/` | Mintlify documentation site | Upstream public docs | +| `packages/enterprise/` | Enterprise/teams app (SolidStart SSR) | Upstream enterprise product | +| `packages/extensions/` | Zed editor extension | Upstream integration | +| `packages/function/` | Cloudflare Worker API (GitHub App auth, webhooks) | Upstream cloud function | +| `packages/identity/` | Brand assets (SVG logos, PNG marks) | Orbit has its own branding | +| `packages/slack/` | Slack bot (@slack/bolt) | Upstream integration | +| `packages/storybook/` | Storybook v10 for SolidJS UI library | SolidJS-specific, not applicable to React | +| `packages/web/` | Marketing/docs site (Astro + Starlight) | Upstream marketing site | +| `github/` | GitHub Action for triggering agent from issue/PR comments | Upstream product feature | +| All `sst-env.d.ts` files | SST environment type declarations (7 files across repo) | SST infra deleted | + +### Kept (Engine) + +| Directory | What it is | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/opencode/` | **The heart** — CLI, HTTP+SSE server, agent orchestration, 30+ tools, 20+ LLM providers, MCP, sessions, permissions, config, storage, TUI | +| `packages/sdk/` | Auto-generated TypeScript SDK client from OpenAPI spec. Already rebranded (`OrbitClient`). | +| `packages/plugin/` | Plugin SDK — `tool()` factory, 20+ hook points, auth hooks | +| `packages/util/` | Shared utilities — arrays, encoding, errors, retry, slugs, paths | +| `packages/script/` | Build script utilities (semver) | +| `patches/` | Bun patch-package overrides for buggy npm deps | +| `script/` | Repo-level scripts — SDK regeneration (`generate.ts`), versioning, release | + +### Kept (Reference) + +| Directory | Category | Why kept | +| ----------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/app/` | Reference | SolidJS web UI — context providers, component patterns, SDK data fetching, SSE event handling | +| `packages/app/src/components/prompt-input/` | Reference | Submit logic, request part building, history navigation, attachment handling | +| `packages/app/src/components/session/` | Reference | Session toolbar, token metrics (pure logic), context breakdown, tab management | +| `packages/app/src/components/server/` | Reference | Server health indicator and connection status | +| `packages/app/src/context/` | Reference | **Most valuable reference layer** — SDK client setup, SSE event reduction, optimistic updates, settings, permissions, layout, file tree, platform abstraction | +| `packages/app/src/hooks/` | Reference | Provider filtering and categorization (popular/connected/paid) for model picker | +| `packages/app/src/pages/layout/` | Reference | Sidebar architecture — project/workspace/session hierarchy, drag-and-drop, deep links | +| `packages/app/src/pages/session/` + `composer/` | Reference | Session view blueprint — message timeline, composer state machine, permission/question/todo docks, session commands, file tabs, terminal panel | +| `packages/app/src/utils/` | Reference | **Most portable directory** — 30/32 files are pure TypeScript. Server health, terminal writer, LRU cache, worktree state machine, aim-aware hover | +| `packages/app/src/` root files | Reference | Provider nesting order (`app.tsx`), platform abstraction (`entry.tsx`), theme variables (`index.css`) | +| `packages/desktop/` | Reference | Tauri v2 desktop app — sidecar lifecycle, native platform abstraction, auto-updater, window chrome, deep links | +| `packages/ui/` | Reference | 175 SolidJS components — theme system, provider icons, audio assets. Keeps `packages/app/` import paths resolvable. | +| `sdks/vscode/` | Reference | VS Code extension — shows how to integrate engine into an external editor | +| `specs/` | Reference | Project spec documents | +| `reviews/` | Reference | Audit plan documents | +| `docs/` | Reference | Internal dev documentation and plans | + +### CLAUDE.md Updated + +- `packages/app/src/components/prompt-input/CLAUDE.md` — created with full file inventory +- `packages/app/src/components/session/CLAUDE.md` — updated with "When to Reference This" table, key data patterns, Orbit mapping +- `packages/app/src/context/CLAUDE.md` — updated with "When to Reference This" table, key data patterns, constants, Orbit mapping +- `packages/app/src/pages/layout/CLAUDE.md` — updated with "When to Reference This" table, portability markers, key data patterns +- `packages/app/src/pages/session/CLAUDE.md` — updated with "When to Reference This" table (17 rows), portability markers, Orbit mapping +- `packages/app/src/pages/session/composer/CLAUDE.md` — updated with focused lookup table +- `packages/app/src/utils/CLAUDE.md` — updated with "When to Reference This" table, portability markers +- `packages/desktop/CLAUDE.md` — updated with "When to Reference This" table (10 rows) + +### Workspaces (Updated in package.json) + +```json +"workspaces": { + "packages": [ + "packages/*", + "packages/sdk/js" + ] +} +``` + +## Status: AUDIT COMPLETE diff --git a/Agent-backend/docs/plans/git/CLAUDE.md b/Agent-backend/docs/plans/git/CLAUDE.md new file mode 100644 index 000000000..68398410a --- /dev/null +++ b/Agent-backend/docs/plans/git/CLAUDE.md @@ -0,0 +1,20 @@ +# git + +> **Path:** `Agent-backend/docs/plans/git/` + +## Purpose + +Git workflow documentation for managing the Orbit fork's relationship with the upstream opencode repository. Covers remote setup, branch strategy, and two sync approaches (tag merge and selective cherry-pick). + +## Usage Status + +| Product | Status | Notes | +| ------------------- | -------- | ------------------------------------------------------------------ | +| Orbit Desktop (SDK) | `active` | Upstream sync strategy directly affects how we receive SDK updates | +| Orbit CLI | `active` | Same | + +## Files + +| File | Purpose | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `upstream-sync.md` | Complete guide — remotes (`origin` = private fork, `upstream` = opencode), branches (`dev` mirrors upstream, `Orbit` for our changes), tag merge vs. selective cherry-pick workflows | diff --git a/Agent-backend/docs/plans/git/upstream-sync.md b/Agent-backend/docs/plans/git/upstream-sync.md new file mode 100644 index 000000000..0b011ea05 --- /dev/null +++ b/Agent-backend/docs/plans/git/upstream-sync.md @@ -0,0 +1,672 @@ +# Upstream Sync Reference Guide + +This repo is a private fork of [anomalyco/opencode](https://github.com/anomalyco/opencode) maintained at [Recusive/Orbit-infra](https://github.com/Recusive/Orbit-infra). Our custom modifications live on the `Orbit` branch. This fork serves as the **AI SDK server** for the Snowflake desktop app — it runs as a Tauri sidecar and exposes an HTTP + SSE API that the React frontend consumes directly. + +**Two sync strategies are documented below:** + +1. **Tag Merge** (§ Syncing Upstream Releases) — merge entire release tags. Best when divergence is small. +2. **Selective Cherry-Pick** (§ Selective Sync) — diff a release, hand-pick what you want. Best after heavy rebrand/customization. + +--- + +## Current Baseline + +| Field | Value | +| ------------------------- | ---------------------------------------------- | +| **Last synced tag** | `v1.2.24` | +| **Custom commits on top** | 1 (`155ab89 feat: custom Orbit modifications`) | +| **Date synced** | March 2026 | + +> **Update this table** every time you sync upstream. This is how future sessions know the starting point. + +--- + +## Two-Repo Workflow + +There are two copies of this codebase that serve different purposes: + +``` +/Users/no9labs/Developer/Recursive/opencode/ ← GIT SYNC HUB + • Has .git with full history + • Has upstream + origin remotes + • Orbit branch for our changes + • This is where merges/cherry-picks happen + +/Users/no9labs/Developer/Recursive/Snowflake-v0/Agent-backend/ ← WORKING COPY + • No .git (lives inside Snowflake-v0's git) + • This is where you develop day-to-day + • Changes flow FROM opencode/ TO here after sync +``` + +### Sync flow + +``` +1. GitHub notification: "opencode v1.2.25 released" + │ +2. cd ~/Developer/Recursive/opencode/ + git fetch upstream --tags + git checkout Orbit + git merge v1.2.25 (or cherry-pick) + │ +3. After resolving conflicts + testing: + Copy changed files → Agent-backend/ + │ +4. Rebuild sidecar: + ./scripts/build-orbit-sdk.sh +``` + +### Copying changes from opencode/ to Agent-backend/ + +After a successful merge in the opencode repo: + +```bash +# See what files changed in the merge +cd /Users/no9labs/Developer/Recursive/opencode +git diff --name-only HEAD~1..HEAD + +# Copy specific changed files (NOT rsync the whole thing — preserves our Agent-backend-only changes) +cp packages/opencode/src/session/prompt.ts \ + /Users/no9labs/Developer/Recursive/Snowflake-v0/Agent-backend/packages/opencode/src/session/prompt.ts + +# Or for a larger sync, use rsync with exclusions: +rsync -av --exclude='node_modules' --exclude='dist' --exclude='.git' \ + packages/opencode/src/provider/ \ + /Users/no9labs/Developer/Recursive/Snowflake-v0/Agent-backend/packages/opencode/src/provider/ +``` + +--- + +## Monitoring Upstream Releases + +Upstream ships ~50 commits/day but tags releases roughly once per day. You need to know **when** a new tag drops and **what changed**. + +### Method 1: GitHub Watch (set up once) + +1. Go to [github.com/anomalyco/opencode](https://github.com/anomalyco/opencode) +2. Click **Watch** → **Custom** → check **Releases only** +3. You'll get an email/GitHub notification whenever they tag a new version + +### Method 2: Quick CLI check (on-demand) + +```bash +cd /Users/no9labs/Developer/Recursive/opencode +git fetch upstream --tags + +# Latest upstream tags +git tag --sort=-creatordate | head -5 + +# Your last sync point +git describe --tags --abbrev=0 Orbit + +# If these differ, there's a new release to review +``` + +### Method 3: check-upstream script (recommended) + +Save this as `scripts/check-upstream.sh` in the opencode repo: + +```bash +#!/bin/bash +# Check for new upstream opencode releases +set -e +cd "$(dirname "$0")/.." + +echo "Fetching upstream tags..." +git fetch upstream --tags --quiet + +CURRENT=$(git describe --tags --abbrev=0 Orbit 2>/dev/null || echo "unknown") +LATEST=$(git tag --sort=-creatordate | head -1) + +echo "" +echo "Your tag: $CURRENT" +echo "Latest tag: $LATEST" + +if [ "$CURRENT" = "$LATEST" ]; then + echo "" + echo "✓ You're up to date." + + # Show unreleased commits on upstream dev + UNRELEASED=$(git log --oneline $LATEST..upstream/dev 2>/dev/null | wc -l | tr -d ' ') + if [ "$UNRELEASED" -gt 0 ]; then + echo " ($UNRELEASED unreleased commits on upstream/dev — wait for next tag)" + fi +else + echo "" + echo "⚠ New release available: $LATEST" + echo "" + echo "=== Files changed in engine (packages/opencode/src/) ===" + git diff --stat $CURRENT..$LATEST -- packages/opencode/src/ 2>/dev/null || echo "(no engine changes)" + echo "" + echo "=== Commits ===" + git log --oneline $CURRENT..$LATEST -- packages/opencode/src/ 2>/dev/null || echo "(none)" + echo "" + + # Show changes in critical directories + for dir in session server provider tool bus permission mcp; do + COUNT=$(git diff --name-only $CURRENT..$LATEST -- packages/opencode/src/$dir/ 2>/dev/null | wc -l | tr -d ' ') + if [ "$COUNT" -gt 0 ]; then + echo " $dir/ — $COUNT files changed" + fi + done + + echo "" + echo "Run: git checkout Orbit && git merge $LATEST" + echo " or use selective cherry-pick (see upstream-sync.md)" +fi +``` + +Run it anytime: + +```bash +cd /Users/no9labs/Developer/Recursive/opencode +./scripts/check-upstream.sh +``` + +Example output when a new release exists: + +``` +Your tag: v1.2.24 +Latest tag: v1.2.25 + +⚠ New release available: v1.2.25 + +=== Files changed in engine (packages/opencode/src/) === + session/prompt.ts | 12 ++++++--- + provider/adapters/ | 3 +++ + 2 files changed, 10 insertions(+), 5 deletions(-) + + session/ — 1 files changed + provider/ — 1 files changed + +Run: git checkout Orbit && git merge v1.2.25 + or use selective cherry-pick (see upstream-sync.md) +``` + +--- + +## Remotes + +| Remote | URL | Purpose | +| ---------- | --------------------------------------------- | ------------------------------- | +| `origin` | `https://github.com/Recusive/Orbit-infra.git` | Private repo (our code) | +| `upstream` | `https://github.com/anomalyco/opencode.git` | Public opencode repo (upstream) | + +## Branches + +| Branch | Tracks | Purpose | +| ------- | -------------- | ----------------------------------------------------------- | +| `dev` | `upstream/dev` | Clean mirror of upstream. **Never commit directly here.** | +| `Orbit` | `origin/Orbit` | Our custom changes, merged on `dev`. All work happens here. | + +--- + +## Day-to-Day Development + +Work on the `Orbit` branch. Commit and push normally: + +```bash +git checkout Orbit +# make changes +git add +git commit -m "description of change" +git push origin Orbit +``` + +--- + +## Syncing Upstream Releases + +**Sync on release tags, not individual commits.** Tagged releases are tested and stable. See § Monitoring Upstream Releases for how to know when a new tag drops. + +### Step 1: Fetch upstream and tags + +```bash +git fetch upstream --tags +``` + +### Step 2: Check for new releases + +```bash +git tag --sort=-creatordate | head -10 +``` + +Compare against the last tag you merged (check merge log): + +```bash +git log --oneline Orbit --grep="Merge tag" | head -5 +``` + +### Step 3: Preview what's coming + +Before merging, understand the scope: + +```bash +# What files did upstream change since last release? +git diff --name-only v1.2.24..v1.2.25 + +# What files have WE changed? +git diff --name-only dev..Orbit + +# Which files OVERLAP (potential conflicts)? +comm -12 \ + <(git diff --name-only dev..Orbit | sort) \ + <(git diff --name-only v1.2.24..v1.2.25 | sort) +``` + +### Step 4: Merge the release tag + +```bash +git checkout Orbit +git merge v1.2.25 +``` + +Three outcomes per file: + +| Category | What happens | Action needed | +| ----------------------------------- | ---------------- | ---------------- | +| Files only **upstream** changed | Auto-merged | None | +| Files only **you** changed | Untouched | None | +| Files **both** changed (same lines) | Conflict markers | Resolve manually | + +### Step 5: Resolve conflicts (if any) and push + +```bash +# see conflicted files +git status + +# fix each one, then +git add +git commit # git pre-fills "Merge tag v1.2.25" +git push origin Orbit +``` + +--- + +## Interactive Conflict Resolution + +When conflicts arise during a merge, use this process to review and resolve them one by one with full control. + +### Step 1: Start the merge + +```bash +git checkout Orbit +git merge v1.2.25 +``` + +If there are conflicts, git will stop and report them. + +### Step 2: List all conflicts + +```bash +git diff --name-only --diff-filter=U +``` + +This prints every conflicted file. Example output: + +``` +packages/opencode/src/session/prompt.ts +packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +bun.lock +``` + +### Step 3: For each file, review the conflict + +Before touching anything, see what both sides did: + +```bash +# Show our version vs their version for a specific file +git diff --merge HEAD...MERGE_HEAD -- + +# Or open the file and look at the markers: +# <<<<<<< HEAD (Orbit — our code) +# ... our version ... +# ======= +# ... their version ... +# >>>>>>> v1.2.25 (upstream) +``` + +### Step 4: Pick a resolution strategy per file + +For each conflicted file, you have four options: + +```bash +# Option A: Keep OUR version (discard upstream's changes to this file) +git checkout --ours +git add + +# Option B: Take THEIR version (discard our changes to this file) +git checkout --theirs +git add + +# Option C: Manually merge (open file, edit, combine both) +# edit the file, remove <<<<<<< ======= >>>>>>> markers +git add + +# Option D: Skip this file for now, come back later +# just don't git add it yet +``` + +### Step 5: Verify and commit + +```bash +# Confirm no conflicts remain +git diff --name-only --diff-filter=U + +# If empty, all conflicts resolved — commit +git commit +git push origin Orbit +``` + +### Step 6: Abort if needed + +If the merge is too messy and you want to start over: + +```bash +git merge --abort +``` + +This returns you to exactly where you were before the merge. Nothing lost. + +--- + +## Rename Strategy + +When renaming `opencode` → `Orbit`, do it in exactly **two commits** to preserve git's rename tracking: + +### Commit 1: File/folder renames only + +```bash +git mv packages/opencode packages/orbit +# fix import paths to match new folder +# NO logic changes, NO branding changes +git commit -m "rename: packages/opencode → packages/orbit" +``` + +Git detects this as a rename (high similarity %). Future upstream merges will apply their `packages/opencode/` changes to your `packages/orbit/` paths automatically. + +### Commit 2: Branding/string changes + +```bash +# Replace user-facing strings: "OpenCode" → "Orbit" in prompts, UI, CLI output +# Leave internal plumbing alone (@opencode-ai/*, .opencode/ dirs, config keys) +git commit -m "brand: opencode → Orbit in user-facing strings" +``` + +### What to rename vs what to leave alone + +| Rename to "Orbit" | Leave as "opencode" | +| ----------------------- | ------------------------------ | +| System prompts | `@opencode-ai/*` package names | +| CLI output / help text | `.opencode/` config directory | +| UI labels and titles | Internal variable names | +| Window titles, branding | Import paths | +| README / docs | `opencode.json` config files | + +Renaming internal plumbing creates ~300 extra conflict points for zero user-facing value. + +--- + +## How Merge Conflicts Actually Work + +Upstream does 50 commits/day but most touch files you haven't modified. The conflict math: + +``` +48 files upstream changed ← auto-merged (you didn't touch them) +16 files you changed ← untouched (they didn't touch them) + 4 files both changed ← potential conflicts + 2 files same-line overlap ← actual conflicts you resolve +``` + +Real-world resolution is typically 5-10 minutes per release merge. + +--- + +## Pre-Push Hook + +The repo has a husky pre-push hook that runs `bun turbo typecheck` across all packages. + +- Requires **Bun >= 1.3.10** (check with `bun --version`, upgrade with `bun upgrade`) +- If typecheck fails, fix the errors before pushing +- Only use `--no-verify` as a last resort for non-code pushes + +--- + +## Adding the Remotes from Scratch + +If you clone fresh from the private repo: + +```bash +git clone https://github.com/Recusive/Orbit-infra.git opencode +cd opencode +git remote add upstream https://github.com/anomalyco/opencode.git +git fetch upstream --tags +``` + +Then checkout the branches: + +```bash +git checkout dev +git checkout Orbit +``` + +--- + +## Selective Sync (Cherry-Pick Approach) + +After heavy rebranding or restructuring, full tag merges may produce too many conflicts. Use selective sync instead — diff the release, read what changed, grab only what you want. + +### Step 1: Fetch and identify the new release + +```bash +git fetch upstream --tags +git tag --sort=-creatordate | head -5 +# v1.2.30 ← new +# v1.2.29 ← last one you synced +``` + +### Step 2: See what changed (high-level) + +```bash +# File-level summary +git diff --stat v1.2.29..v1.2.30 -- packages/opencode/src/ + +# Commit-level summary (one line per commit) +git log --oneline v1.2.29..v1.2.30 -- packages/opencode/src/ +``` + +### Step 3: Drill into areas you care about + +Only look at the directories that matter for the SDK: + +```bash +# New providers / model support +git diff v1.2.29..v1.2.30 -- packages/opencode/src/provider/ + +# Tool system changes +git diff v1.2.29..v1.2.30 -- packages/opencode/src/tool/ + +# Session / prompt loop improvements +git diff v1.2.29..v1.2.30 -- packages/opencode/src/session/ + +# MCP updates +git diff v1.2.29..v1.2.30 -- packages/opencode/src/mcp/ + +# Permission system +git diff v1.2.29..v1.2.30 -- packages/opencode/src/permission/ + +# Bus / SSE events +git diff v1.2.29..v1.2.30 -- packages/opencode/src/bus/ + +# Server routes (new endpoints?) +git diff v1.2.29..v1.2.30 -- packages/opencode/src/server/ +``` + +Directories you can **skip** (we don't use these): + +```bash +# Skip — their UI, TUI, desktop shell, website +packages/app/ +packages/tui/ +packages/desktop/ +packages/web/ +``` + +### Step 4: Grab what you want + +**Option A: Copy an entire file** (cleanest for self-contained additions) + +```bash +# Example: they added a new provider adapter +git show v1.2.30:packages/opencode/src/provider/adapters/gemini-2.5.ts > /tmp/gemini.ts +# Review it, then copy into your codebase and adapt naming +``` + +**Option B: Cherry-pick a specific commit** (best for targeted fixes) + +```bash +# Find the exact commit +git log --oneline v1.2.29..v1.2.30 -- packages/opencode/src/session/prompt.ts +# abc1234 fix: SSE memory leak on long-running sessions + +# Apply it +git cherry-pick abc1234 +# If it conflicts (due to our renames), resolve manually +``` + +**Option C: Apply a patch** (best when cherry-pick conflicts but the change is small) + +```bash +# Generate a patch from their commit +git diff v1.2.29..v1.2.30 -- packages/opencode/src/session/prompt.ts > /tmp/prompt-fix.patch + +# Review, then manually apply the relevant hunks to your file +# (since file paths may differ after rebrand) +``` + +### Step 5: Record what you synced + +After cherry-picking, document what you took so future syncs know the baseline: + +```bash +git commit -m "$(cat <<'EOF' +upstream: cherry-pick from v1.2.30 + +Selectively applied: +- feat: gemini-2.5-pro provider adapter (provider/adapters/) +- fix: SSE memory leak on long-running sessions (session/prompt.ts) +- fix: MCP OAuth token refresh race condition (mcp/index.ts) + +Skipped: +- TUI redesign (not applicable) +- New onboarding flow (we have our own) +- Package.json dependency bumps (we manage our own) +EOF +)" +``` + +--- + +## What to Watch For in Upstream Releases + +Not all upstream changes are equal. Prioritize by impact: + +### Always grab (security + stability) + +| Area | Why | Files | +| ------------------- | ---------------------------- | --------------------------------------------- | +| Security fixes | Auth bypass, injection, etc. | `src/auth/`, `src/permission/`, `src/server/` | +| SSE/Bus fixes | Event stream reliability | `src/bus/` | +| Session crash fixes | Data loss prevention | `src/session/` | +| Provider auth fixes | Token refresh, OAuth | `src/provider/`, `src/auth/` | + +### Usually grab (features) + +| Area | Why | Files | +| ------------------------- | ------------------------------ | ----------------------- | +| New provider adapters | More model options for free | `src/provider/` | +| New tools | Expanded agent capabilities | `src/tool/` | +| MCP improvements | Better tool server integration | `src/mcp/` | +| Permission system updates | Better safety defaults | `src/permission/` | +| Prompt loop optimizations | Faster, smarter agent | `src/session/prompt.ts` | + +### Probably skip (their product, not ours) + +| Area | Why | Files | +| ----------------------- | -------------------------------------------- | ------------------- | +| TUI changes | We don't use the terminal UI | `packages/tui/` | +| SolidJS app changes | We have our own React UI | `packages/app/` | +| Desktop shell changes | We have our own Tauri app | `packages/desktop/` | +| CLI command changes | We only use `serve` | `src/index.ts` | +| Website / marketing | Not applicable | `packages/web/` | +| Config schema additions | Review case-by-case — may add useful options | `src/config/` | + +### Review carefully (could break our customizations) + +| Area | Why | Files | +| --------------------------- | ----------------------------------------- | --------------------------- | +| Server route changes | Could conflict with our custom routes | `src/server/routes/` | +| SSE event type changes | Could break our TauriProvider translation | `src/bus/bus-event.ts` | +| Session data model changes | Could break our conversation adapter | `src/session/message-v2.ts` | +| Agent configuration changes | Could conflict with our agent CRUD | `src/agent/` | + +--- + +## Snowflake Integration Context + +This fork is consumed by Snowflake-v0 as a sidecar binary: + +``` +Snowflake-v0/src-tauri/binaries/orbit-sdk-aarch64-apple-darwin + ↑ + Built from: opencode/packages/opencode/ (this repo) + Built with: bun build --compile (standalone binary) + Spawned by: Tauri on app startup + Talks via: HTTP + SSE on localhost: +``` + +### Build and deploy to Snowflake + +```bash +# Build the sidecar binary +cd packages/opencode +bun run build --single + +# Copy to Snowflake +cp dist/opencode-darwin-arm64/bin/opencode \ + /Users/no9labs/Developer/Recursive/Snowflake-v0/src-tauri/binaries/orbit-sdk-aarch64-apple-darwin + +# Test +cd /Users/no9labs/Developer/Recursive/Snowflake-v0 +bunx tauri dev +``` + +### Custom routes we've added (our orbit-\* files) + +These files are ours — upstream will never conflict with them: + +| File | Purpose | +| ------------------------------------- | ----------------------------------------------------- | +| `src/server/routes/orbit-agent.ts` | Agent CRUD (create/update/delete agent definitions) | +| `src/server/routes/orbit-command.ts` | Command CRUD (create/update/delete slash commands) | +| `src/server/routes/orbit-generate.ts` | AI generation (agent defs, command defs, bug reports) | +| `src/server/routes/orbit-browser.ts` | Browser tool bridge (WKWebView ↔ agent tool calls) | + +These are registered via one import in `server.ts`. When syncing upstream changes to `server.ts`, preserve that import line. + +--- + +## Rules + +1. **Never commit directly to `dev`** — it must stay a clean mirror of upstream +2. **All custom work goes on `Orbit`** — this is the only branch with our modifications +3. **Sync on release tags, not individual commits** — tags are tested and stable +4. **Choose your sync strategy per release** — small release → tag merge; big release after heavy customization → cherry-pick +5. **Preview before merging** — always check the overlap before running `git merge` +6. **Per-file conflict resolution** — review each conflict individually, pick ours/theirs/manual +7. **Use `git merge --abort` freely** — if a merge looks bad, abort and reassess +8. **Rename in two commits** — file moves first, content changes second, to preserve rename tracking +9. **Record what you cherry-picked** — commit messages should list what was taken and what was skipped +10. **Always grab security fixes** — watch releases for auth, permission, and injection fixes +11. **Update the baseline table** — after every sync, update § Current Baseline with the new tag and date +12. **Copy to Agent-backend/** — after merging in the opencode repo, copy changed files to `Snowflake-v0/Agent-backend/` (see § Two-Repo Workflow) +13. **Watch for releases** — set up GitHub Watch (releases only) on `anomalyco/opencode` or run `./scripts/check-upstream.sh` periodically diff --git a/Agent-backend/docs/plans/orbit-rebrand.md b/Agent-backend/docs/plans/orbit-rebrand.md new file mode 100644 index 000000000..b2b9eb6e8 --- /dev/null +++ b/Agent-backend/docs/plans/orbit-rebrand.md @@ -0,0 +1,1230 @@ +# Orbit Rebrand Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebrand all user-facing "opencode"/"OpenCode" strings to "Orbit"/"orbit" while keeping internal code (env vars, flags, package imports) unchanged for upstream mergeability. + +**Architecture:** Surgical string replacements across ~40 files. No structural changes. Internal constants (`Flag.OPENCODE_*`, `@opencode-ai/*` imports, env vars) remain untouched. Only what the user sees changes. This is a fork — no backward compatibility needed for existing opencode installations. All contracts (auth username, GitHub triggers, branch prefixes, reader/writer paths) must move together. + +**Tech Stack:** TypeScript, SolidJS TUI components, text prompt files, JSON theme files + +--- + +## Rules + +**Rename to Orbit/orbit:** + +- Display name: "OpenCode" → "Orbit" +- CLI script name: "opencode" → "orbit" +- Config directory: `.opencode/` → `.orbit/` +- Config files: `opencode.json` → `orbit.json`, `opencode.jsonc` → `orbit.jsonc` +- Database file: `opencode.db` → `orbit.db` +- Agent identity in prompts +- Terminal titles, tips, error messages +- GitHub workflow name and trigger commands +- Server descriptions, mDNS names +- HTML page titles (Codex auth pages) +- ACP agent name +- Worktree branch prefix: `opencode/` → `orbit/` +- Shell config markers: `# opencode` → `# orbit` +- Theme file: `opencode.json` → `orbit.json` + +**Keep as-is (upstream compatibility):** + +- All `Flag.OPENCODE_*` constants and env vars (including `OPENCODE_SERVER_PASSWORD` warning text) +- All `@opencode-ai/*` package imports +- All `opencode.ai` URLs (real infrastructure) +- "OpenCode Zen" and "OpenCode Go" — shared upstream infra +- Provider ID `"opencode"` — tied to Zen/Go +- HTTP headers (`x-opencode-*`, User-Agent) +- `process.env.OPENCODE` / `process.env.OPENCODE_PID` +- Plugin npm package names (`opencode-anthropic-auth`) +- VS Code extension ID (`sst-dev.opencode`) +- `OPENCODE_MIGRATIONS` declared global +- Internal variable names and code comments referencing implementation details +- `*.opencode.ai` CORS pattern +- GitHub Action path (`anomalyco/opencode/github@latest`) +- `opencode-agent[bot]` GitHub bot name +- `.well-known/opencode` API path +- `$schema` URLs pointing to `opencode.ai` +- Docker image name (`ghcr.io/anomalyco/opencode`) + +--- + +## Chunk 1: Core Identity & Data Paths + +### Task 1: App name constant (`global/index.ts`) + +**Files:** + +- Modify: `packages/opencode/src/global/index.ts:7` + +- [ ] **Step 1: Change app constant** + +```typescript +// Line 7: change from +const app = "opencode" +// to +const app = "orbit" +``` + +This cascades to XDG paths: `~/.local/share/orbit/`, `~/.config/orbit/`, etc. + +- [ ] **Step 2: Update comment on line 16** + +```typescript +// Line 16: change from +// Allow override via OPENCODE_TEST_HOME for test isolation +// to (KEEP - this is about the env var, which stays) +``` + +No change needed — comment references the env var which stays. + +--- + +### Task 2: Database filename (`storage/db.ts`) + +**Files:** + +- Modify: `packages/opencode/src/storage/db.ts:34,36` + +- [ ] **Step 1: Change db filenames** + +```typescript +// Line 34: change "opencode.db" → "orbit.db" +return path.join(Global.Path.data, "orbit.db") + +// Line 36: change "opencode-" → "orbit-" +return path.join(Global.Path.data, `orbit-${safe}.db`) +``` + +--- + +### Task 3: CLI entry point (`index.ts`) + +**Files:** + +- Modify: `packages/opencode/src/index.ts:56,86,91` + +- [ ] **Step 1: Change scriptName** + +```typescript +// Line 56: change +.scriptName("orbit") +``` + +- [ ] **Step 2: Change log prefix** + +```typescript +// Line 86: change +Log.Default.info("orbit", { +``` + +- [ ] **Step 3: Change migration marker** + +```typescript +// Line 91: change "opencode.db" → "orbit.db" +const marker = path.join(Global.Path.data, "orbit.db") +``` + +--- + +### Task 4: Config paths (`config/paths.ts`) + +**Files:** + +- Modify: `packages/opencode/src/config/paths.ts:28,36` + +- [ ] **Step 1: Change directory targets** + +```typescript +// Line 28: change +targets: [".orbit"], + +// Line 36: change +targets: [".orbit"], +``` + +--- + +### Task 5: Config loading (`config/config.ts`) + +**Files:** + +- Modify: `packages/opencode/src/config/config.ts` (multiple lines) + +- [ ] **Step 1: Change managed config paths (lines ~49, 51, 53)** + +```typescript +;`/Library/Application Support/orbit` +path.join(process.env.ProgramData || "C:\\ProgramData", "orbit")`/etc/orbit` +``` + +- [ ] **Step 2: Change config file name in projectFiles call (line ~126)** + +```typescript +for (const file of await ConfigPaths.projectFiles("orbit", Instance.directory, Instance.worktree)) +``` + +- [ ] **Step 3: Change .opencode directory checks (line ~145)** + +```typescript +if (dir.endsWith(".orbit") || dir === Flag.OPENCODE_CONFIG_DIR) +``` + +- [ ] **Step 4: Change config file names (line ~146)** + +```typescript +for (const file of ["orbit.jsonc", "orbit.json"]) +``` + +- [ ] **Step 5: Change config file names at line ~186** + +```typescript +for (const file of ["orbit.jsonc", "orbit.json"]) +``` + +- [ ] **Step 6: Change global config file paths (lines ~1184-1185)** + +```typescript +mergeDeep(await loadFile(path.join(Global.Path.config, "orbit.json"))) +mergeDeep(await loadFile(path.join(Global.Path.config, "orbit.jsonc"))) +``` + +- [ ] **Step 7: Change config candidates (line ~1299)** + +```typescript +const candidates = ["orbit.jsonc", "orbit.json", "config.json"].map((file) => +``` + +NOTE: Keep all `opencode.ai` URLs, `$schema` URLs, `.well-known/opencode` paths, and comments about env vars as-is. + +--- + +### Task 5b: TUI config migration (`config/migrate-tui-config.ts`) + +**Files:** + +- Modify: `packages/opencode/src/config/migrate-tui-config.ts:140-146` + +- [ ] **Step 1: Change config file references in opencodeFiles()** + +```typescript +// Line 140 +: await ConfigPaths.projectFiles("orbit", Instance.directory, Instance.worktree) +// Line 141 +const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "orbit")] +// Line 143 +files.push(...ConfigPaths.fileInDirectory(dir, "orbit")) +// Line 146 +files.push(...ConfigPaths.fileInDirectory(input.managed, "orbit")) +``` + +--- + +### Task 6: TUI config (`config/tui.ts`) + +**Files:** + +- Modify: `packages/opencode/src/config/tui.ts:57` + +- [ ] **Step 1: Change directory check** + +```typescript +if (!dir.endsWith(".orbit") && dir !== Flag.OPENCODE_CONFIG_DIR) continue +``` + +--- + +### Task 7: MCP config paths and user-facing strings (`cli/cmd/mcp.ts`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/mcp.ts:88,165,385,388,397,484` + +- [ ] **Step 1: Change ROOT config candidates (line ~385)** + +CRITICAL: The root candidates must also change, not just the subdirectory candidates. + +```typescript +// Line ~385: change root candidates from opencode.json/opencode.jsonc to orbit.json/orbit.jsonc +const candidates = [path.join(baseDir, "orbit.jsonc"), path.join(baseDir, "orbit.json")] +``` + +- [ ] **Step 2: Change subdirectory candidates (line ~388)** + +```typescript +candidates.push(path.join(baseDir, ".orbit", "orbit.jsonc"), path.join(baseDir, ".orbit", "orbit.json")) +``` + +- [ ] **Step 3: Change default path comment (line ~397)** + +```typescript +// default to orbit.jsonc in the base directory +``` + +- [ ] **Step 4: Change user-facing help/hint strings** + +```typescript +// Line 88: change "Add servers with: opencode mcp add" → "Add servers with: orbit mcp add" +// Line 165: change "Add a remote server in opencode.json:" → "Add a remote server in orbit.json:" +// Line 484: change placeholder "e.g., opencode x @modelcontextprotocol/..." → "e.g., orbit x @modelcontextprotocol/..." +``` + +NOTE: Keep `clientInfo: { name: "opencode-debug" }` at lines 665/706 as-is — internal debug tool name, not user-facing. + +--- + +### Task 8: Commit + +- [ ] **Step 1: Commit core identity changes** + +```bash +git add -A && git commit -m "feat: rebrand core identity and data paths from opencode to orbit" +``` + +--- + +## Chunk 2: TUI Display Text + +### Task 9: Terminal title (`tui/app.tsx`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/app.tsx` (lines 268, 275, 281, 578, 685, 747, 802) + +- [ ] **Step 1: Change terminal titles (lines 268, 275)** + +```typescript +renderer.setTerminalTitle("Orbit") // line 268 +renderer.setTerminalTitle("Orbit") // line 275 +``` + +- [ ] **Step 2: Change abbreviated title (line 281)** + +```typescript +renderer.setTerminalTitle(`OB | ${title}`) // line 281 +``` + +- [ ] **Step 3: Change update toast (line 747)** + +```typescript +message: `Orbit v${evt.properties.version} is available. Run 'orbit upgrade' to update manually.`, +``` + +- [ ] **Step 4: Change openrouter warning (line 685)** + +Keep "OpenCode Zen" as-is — this is upstream infra the user said to keep. + +- [ ] **Step 5: Keep docs URL (line 578)** + +Keep `https://opencode.ai/docs` as-is — upstream URL. + +- [ ] **Step 6: Keep GitHub issue URL (line 802)** + +Keep `https://github.com/anomalyco/opencode/issues/new` as-is — upstream URL. + +--- + +### Task 10: Tips (`tui/component/tips.tsx`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/component/tips.tsx` + +- [ ] **Step 1: Rename all user-facing opencode references in TIPS array** + +Changes needed (line numbers): + +- Line 57: `opencode.ai` → keep (URL) +- Line 83: `opencode.json` → `orbit.json` +- Line 84: `~/.config/opencode/tui.json` → `~/.config/orbit/tui.json` +- Line 90: `"OpenCode auto-handles..."` → `"Orbit auto-handles..."` +- Line 91: `.opencode/command/` → `.orbit/command/` +- Line 94: `.opencode/agent/` → `.orbit/agent/` +- Line 99: `"OpenCode auto-formats..."` → `"Orbit auto-formats..."` +- Line 102: `"OpenCode uses LSP..."` → `"Orbit uses LSP..."` +- Line 103: `.opencode/tools/` → `.orbit/tools/` +- Line 105: `.opencode/plugin/` → `.orbit/plugin/` +- Line 107: `"...prevent OpenCode from..."` → `"...prevent Orbit from..."` +- Line 108: `opencode run` → `orbit run` +- Line 109: `opencode --continue` → `orbit --continue` +- Line 110: `opencode run -f file.ts` → `orbit run -f file.ts` +- Line 112: `opencode serve` → `orbit serve`, `"OpenCode"` → `"Orbit"` +- Line 113: `opencode run --attach` → `orbit run --attach` +- Line 114: `opencode upgrade` → `orbit upgrade` +- Line 115: `opencode auth list` → `orbit auth list` +- Line 116: `opencode agent create` → `orbit agent create` +- Line 117: `/opencode` → `/orbit` +- Line 118: `opencode github install` → `orbit github install` +- Line 119: `/opencode fix this` → `/orbit fix this` +- Line 122: `.opencode/themes/` → `.orbit/themes/` +- Line 138: `opencode debug config` → `orbit debug config` +- Line 145: Keep docker image path as-is (upstream) +- Line 146: Keep "OpenCode Zen" as-is (upstream infra) + +--- + +### Task 11: Dialog status (`tui/component/dialog-status.tsx`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx:82` + +- [ ] **Step 1: Change MCP auth message** + +```typescript +Needs authentication (run: orbit mcp auth {key}) +``` + +--- + +### Task 12: Dialog provider (`tui/component/dialog-provider.tsx`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx` + +Keep all "OpenCode Zen" and "OpenCode Go" references as-is — user explicitly said these are shared upstream infra. + +No changes needed. + +--- + +### Task 13: Theme file rename and references + +**Files:** + +- Rename: `packages/opencode/src/cli/cmd/tui/context/theme/opencode.json` → `orbit.json` +- Modify: `packages/opencode/src/cli/cmd/tui/context/theme.tsx:27,290,310,333,358,402` + +- [ ] **Step 1: Rename the theme file** + +```bash +git mv .../theme/opencode.json .../theme/orbit.json +``` + +- [ ] **Step 2: Update import (line 27)** + +```typescript +import orbit from "./theme/orbit.json" +``` + +- [ ] **Step 3: Update DEFAULT_THEMES to export `orbit` with legacy alias** + +In the DEFAULT_THEMES object, replace `opencode` with `orbit` and add legacy alias: + +```typescript +orbit, +opencode: orbit, // legacy alias for theme persistence +``` + +- [ ] **Step 4: Update default theme selection (line ~290)** + +```typescript +active: (config.theme ?? kv.get("theme", "orbit")) as string, +``` + +- [ ] **Step 5: Update error fallback (line ~310)** + +```typescript +setStore("active", "orbit") +``` + +- [ ] **Step 6: Update system theme failure fallback (line ~333)** + +```typescript +draft.active = "orbit" +``` + +- [ ] **Step 7: Update computed fallback (line 358)** + +```typescript +return resolveTheme(store.themes[store.active] ?? store.themes.orbit, store.mode) +``` + +- [ ] **Step 8: Update custom theme directory target (line 402)** + +```typescript +targets: [".orbit"], +``` + +NOTE: The theme JSON files' `$schema` URLs pointing to `opencode.ai/theme.json` stay as-is. + +--- + +### Task 13b: Sidebar getting-started copy (`tui/routes/session/sidebar.tsx`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx:295` + +- [ ] **Step 1: Change getting-started text** + +```typescript +// Line 295 +Orbit includes free models so you can start immediately. +``` + +--- + +### Task 13c: Permission dialog copy (`tui/routes/session/permission.tsx`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx:160,164,504` + +- [ ] **Step 1: Change permission dialog strings** + +```typescript +// Line 160 + + +// Line 164 +This will allow the following patterns until Orbit is restarted + +// Line 504 +Tell Orbit what to do differently +``` + +--- + +### Task 13d: Thread help text (`tui/thread.ts`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/thread.ts:67,72` + +- [ ] **Step 1: Change user-facing command descriptions** + +```typescript +// Line 67 +describe: "start orbit tui", + +// Line 72 +describe: "path to start orbit in", +``` + +--- + +### Task 13e: Session resume command (`tui/routes/session/index.tsx`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:275` + +- [ ] **Step 1: Change user-facing CLI command in session footer** + +```typescript +;` ${weak("Continue")}${UI.Style.TEXT_NORMAL_BOLD}orbit -s ${session()?.id}${UI.Style.TEXT_NORMAL}` +``` + +--- + +### Task 14: Commit + +- [ ] **Step 1: Commit TUI display changes** + +```bash +git add -A && git commit -m "feat: rebrand TUI display text from opencode to orbit" +``` + +--- + +## Chunk 3: Agent Prompts + +### Task 15: All prompt .txt files + +**Files:** + +- Modify: `packages/opencode/src/session/prompt/anthropic.txt:1,10,12` +- Modify: `packages/opencode/src/session/prompt/beast.txt:1` +- Modify: `packages/opencode/src/session/prompt/qwen.txt:1,8,9,11` +- Modify: `packages/opencode/src/session/prompt/codex_header.txt:1` +- Modify: `packages/opencode/src/session/prompt/copilot-gpt-5.txt:2` +- Modify: `packages/opencode/src/session/prompt/trinity.txt:1` +- Modify: `packages/opencode/src/session/prompt/gemini.txt:1` + +- [ ] **Step 1: anthropic.txt** + +``` +Line 1: "You are Orbit, the best coding agent on the planet." +Line 10: keep GitHub URL as-is +Line 12: "When the user directly asks about Orbit (eg. "can Orbit do...", "does Orbit have..."), ... from Orbit docs." + Keep the opencode.ai/docs URL. +``` + +- [ ] **Step 2: beast.txt** + +``` +Line 1: "You are orbit, an agent..." +``` + +- [ ] **Step 3: qwen.txt** + +``` +Line 1: "You are orbit, an interactive CLI tool..." +Line 8: "/help: Get help with using orbit" +Line 9: keep GitHub URL as-is +Line 11: "...directly asks about orbit...from orbit docs at https://opencode.ai" +``` + +- [ ] **Step 4: codex_header.txt** + +``` +Line 1: "You are Orbit, the best coding agent on the planet." +``` + +- [ ] **Step 5: copilot-gpt-5.txt** + +``` +Line 2: "Your name is orbit" +``` + +- [ ] **Step 6: trinity.txt** + +``` +Line 1: "You are orbit, an interactive CLI tool..." +``` + +- [ ] **Step 7: gemini.txt** + +``` +Line 1: "You are orbit, an interactive CLI agent..." +``` + +--- + +### Task 16: Commit + +- [ ] **Step 1: Commit prompt changes** + +```bash +git add -A && git commit -m "feat: rebrand agent prompts from opencode to orbit" +``` + +--- + +## Chunk 4: CLI Commands & Server + +### Task 17: serve.ts + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/serve.ts:12,19` + +- [ ] **Step 1: Change descriptions** + +```typescript +// Line 12 +describe: ("starts a headless orbit server", + // Line 19 + console.log(`orbit server listening on http://${server.hostname}:${server.port}`)) +``` + +NOTE: Keep OPENCODE_SERVER_PASSWORD warning as-is (env var name). + +--- + +### Task 18: web.ts + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/web.ts:34` + +- [ ] **Step 1: Change description** + +```typescript +describe: "start orbit server and open web interface", +``` + +--- + +### Task 20: uninstall.ts + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/uninstall.ts:218,232,269,287,294,300,301` + +- [ ] **Step 1: Change all .opencode references to .orbit** + +```typescript +// Line 218 +if (binDir.includes(".orbit")) + // Line 232 + prompts.log.success("Thank you for using Orbit!") + +// Line 269 +if (content.includes("# orbit") || content.includes(".orbit/bin")) + if (trimmed === "# orbit") + // Line 287 + if (trimmed.includes(".orbit/bin") || trimmed.includes("fish_add_path")) + // Line 294 + // Line 300 + (trimmed.startsWith("export PATH=") && trimmed.includes(".orbit/bin"))( + // Line 301 + trimmed.startsWith("fish_add_path") && trimmed.includes(".orbit"), + ) +``` + +--- + +### Task 21: github.ts — workflow, triggers, branches, console output + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/github.ts` (multiple lines) + +CRITICAL: Workflow generation AND runtime parsing must agree on trigger commands and branch prefixes. + +- [ ] **Step 1: Change workflow YAML generation (lines 380-408)** + +```yaml +name: orbit +jobs: + orbit: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /orbit') || + startsWith(github.event.comment.body, '/orbit') + steps: + - name: Run orbit + uses: anomalyco/opencode/github@latest # keep upstream action +``` + +- [ ] **Step 2: Change runtime mention parsing (line 786)** + +```typescript +const mentions = process.env["MENTIONS"] || "/orbit,/oc" +``` + +- [ ] **Step 3: Change GitHub-generated branch prefix (lines 1131, 1133)** + +```typescript +return `orbit/${type}-${hex}-${timestamp}` +return `orbit/${type}${issueId}-${timestamp}` +``` + +- [ ] **Step 4: Change console output (lines 564, 933)** + +```typescript +console.log("orbit session", session.id) +console.log("Sending message to orbit...") +``` + +- [ ] **Step 5: Change agent prompt references (lines 1468, 1606)** + +```typescript +"- Git push and PR creation are handled AUTOMATICALLY by the orbit infrastructure after your response", +``` + +- [ ] **Step 6: Change share link text (line 1407)** + +```typescript +const shareUrl = shareId ? `[orbit session](${shareBaseUrl}/s/${shareId})  |  ` : "" +``` + +Keep: AGENT_USERNAME (`opencode-agent[bot]`), WORKFLOW_FILE path, GitHub App URL, API URLs, share base URLs, social card URLs, `/oc` shorthand, GitHub Action path. + +--- + +### Task 22: Server auth + display (`server/server.ts`) + +**Files:** + +- Modify: `packages/opencode/src/server/server.ts:82,224,226,256,278,341,415,437,578,580` + +CRITICAL: Default auth username must match in server AND all clients (run.ts, attach.ts, worker.ts). + +- [ ] **Step 1: Change default username (line 82)** + +```typescript +const username = Flag.OPENCODE_SERVER_USERNAME ?? "orbit" +``` + +- [ ] **Step 2: Change OpenAPI titles and descriptions** + +```typescript +title: "orbit", +description: "orbit api", +``` + +(Both occurrences at lines 224/226 and 578/580) + +- [ ] **Step 3: Change API endpoint descriptions** + +Replace "OpenCode" with "Orbit" in all `description:` strings (lines 256, 278, 341, 415, 437). + +Keep `*.opencode.ai` CORS pattern and `app.opencode.ai` proxy as-is. + +--- + +### Task 22b: Client auth username — run.ts + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/run.ts:223,281,564,659` + +- [ ] **Step 1: Change help text** + +```typescript +describe: "run orbit with a message", // line 223 +describe: "attach to a running orbit server (e.g., http://localhost:4096)", // line 281 +console.log("orbit session", session.id) // line 564 +``` + +- [ ] **Step 2: Change auth username default (line 659)** + +```typescript +const username = process.env.OPENCODE_SERVER_USERNAME ?? "orbit" +``` + +--- + +### Task 22c: Client auth username — attach.ts + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/attach.ts:11,66` + +- [ ] **Step 1: Change help text (line 11)** + +```typescript +describe: "attach to a running orbit server", +``` + +- [ ] **Step 2: Change auth username (line 66)** + +CRITICAL: Do NOT hardcode `"orbit"`. Use the same env-backed pattern as `server.ts`, `run.ts`, and `worker.ts` so custom `OPENCODE_SERVER_USERNAME` is honored. + +```typescript +const username = process.env.OPENCODE_SERVER_USERNAME ?? "orbit" +const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +``` + +--- + +### Task 22d: Client auth username — worker.ts + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/tui/worker.ts:155` + +- [ ] **Step 1: Change default username** + +```typescript +const username = Flag.OPENCODE_SERVER_USERNAME ?? "orbit" +``` + +--- + +### Task 23: Server routes descriptions + +**Files:** + +- Modify: `packages/opencode/src/server/routes/global.ts:24,45,114,135` +- Modify: `packages/opencode/src/server/routes/project.ts:17,39` +- Modify: `packages/opencode/src/server/routes/config.ts:19,40` +- Modify: `packages/opencode/src/server/routes/pty.ts:16` +- Modify: `packages/opencode/src/server/routes/session.ts:28,96,189` +- Modify: `packages/opencode/src/server/routes/experimental.ts:195` + +- [ ] **Step 1: Replace "OpenCode" with "Orbit" in all API description strings** + +--- + +### Task 24: mDNS and network defaults + +**Files:** + +- Modify: `packages/opencode/src/server/mdns.ts:15,16` +- Modify: `packages/opencode/src/cli/network.ts:22,23` + +- [ ] **Step 1: Change mDNS names** + +```typescript +// mdns.ts +const host = domain ?? "orbit.local" +const name = `orbit-${port}` + +// network.ts +describe: "custom domain name for mDNS service (default: orbit.local)", +default: "orbit.local", +``` + +--- + +### Task 24b: upgrade.ts + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/upgrade.ts:8,30,48` + +- [ ] **Step 1: Change help text and messages** + +```typescript +describe: ("upgrade orbit to the latest or a specific version", + prompts.log.error(`orbit is installed to ${process.execPath}...`)) +prompts.log.warn(`orbit upgrade skipped: ${target} is already installed`) +``` + +--- + +### Task 24c: pr.ts + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/pr.ts:9,90,112,126` + +- [ ] **Step 1: Change display text** + +```typescript +describe: ("fetch and checkout a GitHub PR branch, then run orbit", UI.println(`Found orbit session: ${sessionUrl}`)) +UI.println("Starting orbit...") +reject(new Error(`orbit exited with code ${code}`)) +``` + +--- + +### Task 25: Provider error messages (`provider/error.ts`) + +**Files:** + +- Modify: `packages/opencode/src/provider/error.ts:45,84` + +- [ ] **Step 1: Change error messages** + +```typescript +return "Please reauthenticate with the copilot provider to ensure your credentials work properly with Orbit." + +return "Unauthorized: ... try running `orbit auth login ` to re-authenticate." +``` + +--- + +### Task 26: Retry messages (`session/retry.ts`) + +Keep as-is — references opencode.ai/zen URL (upstream infra). + +--- + +### Task 27: Commit + +- [ ] **Step 1: Commit CLI and server changes** + +```bash +git add -A && git commit -m "feat: rebrand CLI commands and server display from opencode to orbit" +``` + +--- + +## Chunk 5: Misc Source Files + +### Task 28: Worktree branch prefix (`worktree/index.ts`) + +**Files:** + +- Modify: `packages/opencode/src/worktree/index.ts:272` + +- [ ] **Step 1: Change branch prefix** + +```typescript +const branch = `orbit/${name}` +``` + +--- + +### Task 29: Agent plan paths (`agent/agent.ts`) + +**Files:** + +- Modify: `packages/opencode/src/agent/agent.ts:106` + +- [ ] **Step 1: Change .opencode path** + +```typescript +[path.join(".orbit", "plans", "*.md")]: "allow", +``` + +--- + +### Task 30: Session plan path (`session/index.ts`) + +**Files:** + +- Modify: `packages/opencode/src/session/index.ts:335` + +- [ ] **Step 1: Change .opencode path** + +```typescript +? path.join(Instance.worktree, ".orbit", "plans") +``` + +--- + +### Task 31: Ripgrep filter (`file/ripgrep.ts`) + +**Files:** + +- Modify: `packages/opencode/src/file/ripgrep.ts:294` + +- [ ] **Step 1: Change .opencode exclusion** + +```typescript +if (file.includes(".orbit")) continue +``` + +--- + +### Task 32: Installation path check (`installation/index.ts`) + +**Files:** + +- Modify: `packages/opencode/src/installation/index.ts:94` + +- [ ] **Step 1: Change .opencode path check** + +```typescript +if (process.execPath.includes(path.join(".orbit", "bin"))) return "curl" +``` + +--- + +### Task 33: Skill scanning (`skill/skill.ts`) + +**Files:** + +- Modify: `packages/opencode/src/skill/skill.ts:122` (comment only) + +- [ ] **Step 1: Update comment** + +```typescript +// Scan .orbit/skill/ directories +``` + +NOTE: The actual scanning uses `Config.directories()` which already changed in Task 4/5. + +--- + +### Task 34: Codex auth HTML pages (`plugin/codex.ts`) + +**Files:** + +- Modify: `packages/opencode/src/plugin/codex.ts:149,180,191` + +- [ ] **Step 1: Change HTML titles and text** + +```html + +Orbit - Codex Authorization Successful + + +

You can close this window and return to Orbit.

+ + +Orbit - Codex Authorization Failed +``` + +--- + +### Task 35: Agent writer path (`cli/cmd/agent.ts`) + +**Files:** + +- Modify: `packages/opencode/src/cli/cmd/agent.ts:102` + +CRITICAL: Must match the reader path in config/paths.ts (`.orbit`). + +- [ ] **Step 1: Change project agent directory** + +```typescript +scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".orbit"), +``` + +--- + +### Task 36: ACP agent info (`acp/agent.ts`) + +**Files:** + +- Modify: `packages/opencode/src/acp/agent.ts:522,523,531,533,558` + +- [ ] **Step 1: Change user-facing display strings** + +```typescript +description: "Run `orbit auth login` in the terminal", +name: "Login with orbit", +command: "orbit", +label: "Orbit Login", +name: "Orbit", // agentInfo.name +``` + +Keep provider ID `"opencode"` references in logic checks (lines 1566-1591). + +--- + +### Task 37: OAuth callback HTML (`mcp/oauth-callback.ts`) + +**Files:** + +- Modify: `packages/opencode/src/mcp/oauth-callback.ts:10,21,30` + +- [ ] **Step 1: Change HTML titles and text** + +```html +Orbit - Authorization Successful +

You can close this window and return to Orbit.

+Orbit - Authorization Failed +``` + +--- + +### Task 38: OAuth provider client name (`mcp/oauth-provider.ts`) + +**Files:** + +- Modify: `packages/opencode/src/mcp/oauth-provider.ts:41` + +- [ ] **Step 1: Change client display name** + +```typescript +client_name: "Orbit", +``` + +Keep `client_uri: "https://opencode.ai"` as-is. + +--- + +### Task 39: MCP SDK client name and auth message (`mcp/index.ts`) + +**Files:** + +- Modify: `packages/opencode/src/mcp/index.ts:387,429,472` + +- [ ] **Step 1: Change SDK client name and auth message** + +```typescript +// Lines 387, 472 +name: "orbit", + +// Line 429 +message: `Server "${key}" requires authentication. Run: orbit mcp auth ${key}` +``` + +--- + +### Task 40: IDE extension (`ide/index.ts`) + +Keep as-is — `sst-dev.opencode` is an external VS Code extension ID. + +--- + +### Task 41: Commit + +- [ ] **Step 1: Commit misc source file changes** + +```bash +git add -A && git commit -m "feat: rebrand misc source files from opencode to orbit" +``` + +--- + +## Chunk 6: Packaging & Binary + +### Task 42: package.json bin alias + +**Files:** + +- Modify: `packages/opencode/package.json:21-23` + +- [ ] **Step 1: Add orbit binary alias** + +```json +"bin": { + "opencode": "./bin/opencode", + "orbit": "./bin/opencode" +}, +``` + +This ensures both `opencode` (upstream compat) and `orbit` (user-facing) work as CLI commands. + +--- + +### Task 43: Launcher cache path (`bin/opencode`) + +**Files:** + +- Modify: `packages/opencode/bin/opencode:29,53,54,172` + +- [ ] **Step 1: Change cache binary path (line 29)** + +```javascript +const cached = path.join(scriptDir, ".orbit") +``` + +- [ ] **Step 2: Change platform binary names (lines 53-54)** + +```javascript +const base = "opencode-" + platform + "-" + arch // KEEP - npm package names +const binary = platform === "windows" ? "opencode.exe" : "opencode" // KEEP - upstream binary names +``` + +Keep lines 53-54 as-is — these reference npm platform package names which stay upstream. + +- [ ] **Step 3: Change error message (line 172)** + +```javascript +"It seems that your package manager failed to install the right version of the orbit CLI for your platform. ..." +``` + +--- + +### Task 44: Postinstall cache path (`script/postinstall.mjs`) + +**Files:** + +- Modify: `packages/opencode/script/postinstall.mjs:92,112,121` + +- [ ] **Step 1: Change console messages** + +```javascript +// Line 92 +console.log(`orbit binary symlinked: ${targetPath} -> ${sourcePath}`) + +// Line 112 +const target = path.join(__dirname, "bin", ".orbit") + +// Line 121 +console.error("Failed to setup orbit binary:", error.message) +``` + +Keep npm package name references (`opencode-${platform}-${arch}`) as-is. + +--- + +### Task 45: Commit + +- [ ] **Step 1: Commit packaging changes** + +```bash +git add -A && git commit -m "feat: add orbit binary alias and update packaging" +``` + +--- + +## Summary + +**Total files modified:** ~40 source files + 1 theme file rename + packaging files +**Total files left unchanged:** All env vars, flags, package imports, URLs, internal code +**Upstream merge risk:** Low — changes concentrated in display strings and path constants + +**Consistency checks (all must match):** + +- Server auth default: `"orbit"` in `server.ts`, `run.ts`, `attach.ts`, `worker.ts` +- GitHub triggers: workflow generates `/orbit` + `/oc`, runtime parses `/orbit,/oc` +- Branch prefix: `orbit/` in `worktree/index.ts` AND `github.ts` +- Config paths: `.orbit` in readers (`paths.ts`, `config.ts`, `tui.ts`) AND writers (`agent.ts`, `mcp.ts`) +- Config files: `orbit.json`/`orbit.jsonc` in `config.ts`, `mcp.ts` (root AND subdirectory candidates), `migrate-tui-config.ts`, tips +- Theme: renamed to `orbit` with `opencode` legacy alias, all defaults/fallbacks use `"orbit"` +- Binary: `orbit` alias added in `package.json` bin field diff --git a/Agent-backend/eslint.config.ts b/Agent-backend/eslint.config.ts new file mode 100644 index 000000000..e94739e83 --- /dev/null +++ b/Agent-backend/eslint.config.ts @@ -0,0 +1,174 @@ +import eslint from "@eslint/js" +import { defineConfig } from "eslint/config" +import importPlugin from "eslint-plugin-import-x" +import tseslint from "typescript-eslint" + +export default defineConfig( + { + ignores: [ + "dist/", + "node_modules/", + "*.config.js", + "*.config.mjs", + // Reference packages — read-only, do not lint + "packages/app/", + "packages/desktop/", + "packages/ui/", + "sdks/", + // Auto-generated SDK code + "packages/sdk/js/src/gen/", + "packages/sdk/js/src/v2/gen/", + // Migration files + "packages/opencode/migration/", + ], + }, + eslint.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + plugins: { + // @ts-expect-error - Plugin types have legacy config format incompatibility + "import-x": importPlugin, + }, + rules: { + /* TypeScript strict — matches Orbit's eslint.config.ts */ + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-call": "error", + "@typescript-eslint/no-unsafe-return": "error", + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/restrict-template-expressions": "error", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/no-misused-promises": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { prefer: "type-imports", fixStyle: "separate-type-imports" }, + ], + "@typescript-eslint/consistent-type-exports": "error", + "@typescript-eslint/explicit-function-return-type": [ + "error", + { allowExpressions: true, allowTypedFunctionExpressions: true }, + ], + "@typescript-eslint/explicit-module-boundary-types": "error", + "@typescript-eslint/no-unnecessary-condition": "error", + "@typescript-eslint/prefer-nullish-coalescing": "error", + "@typescript-eslint/strict-boolean-expressions": [ + "error", + { allowNullableBoolean: true, allowNullableString: true }, + ], + "@typescript-eslint/switch-exhaustiveness-check": "error", + "@typescript-eslint/no-non-null-assertion": "error", + /* Disable: conflicts with no-non-null-assertion. When `!` is forbidden, + `as T` is the only option for non-nullable assertions. */ + "@typescript-eslint/non-nullable-type-assertion-style": "off", + + /* Allow _prefix for intentionally unused variables (interface compliance, destructuring) */ + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], + + /* Imports — matches Orbit */ + "import-x/order": [ + "error", + { + groups: ["builtin", "external", "internal", "parent", "sibling", "index", "type"], + "newlines-between": "always", + alphabetize: { order: "asc" }, + }, + ], + "import-x/no-duplicates": "error", + "import-x/consistent-type-specifier-style": ["error", "prefer-top-level"], + + /* Codebase-specific: namespaces are the primary organizational pattern (upstream fork) */ + "@typescript-eslint/no-namespace": "off", + + /* General — matches Orbit */ + "no-console": ["error", { allow: ["warn", "error"] }], + eqeqeq: ["error", "always"], + }, + }, + /* Test files — relaxed type checking (matches Orbit) */ + { + files: ["**/test/**/*.ts", "**/*.test.ts", "**/*.spec.ts"], + rules: { + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-explicit-any": "off", + /* Bun test types declare expect().rejects as Matchers (sync) + but the runtime is actually thenable — awaiting is correct behavior. + This also means `await expect(x).rejects.toThrow()` triggers + no-confusing-void-expression since toThrow() is typed as void. */ + "@typescript-eslint/await-thenable": "off", + "@typescript-eslint/no-confusing-void-expression": "off", + /* Test mocks implement interfaces with async methods but return sync values. + Non-null assertions are safe after expect().toBeDefined() guards. */ + "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unnecessary-type-assertion": "off", + }, + }, + /* CLI commands — console.log is expected user-facing output */ + { + files: ["packages/opencode/src/cli/**/*.ts", "packages/opencode/src/cli/**/*.tsx"], + rules: { + "no-console": "off", + }, + }, + /* Logging utility — wraps console by design */ + { + files: ["packages/opencode/src/util/log.ts"], + rules: { + "no-console": "off", + }, + }, + /* Scripts — CLI tools that use console for output */ + { + files: [ + "script/**/*.ts", + "packages/opencode/script/**/*.ts", + "packages/opencode/script/**/*.mjs", + "packages/script/src/**/*.ts", + ], + rules: { + "no-console": "off", + }, + }, + /* JS fixture files — no type checking available */ + { + files: ["**/*.js", "**/*.mjs"], + ...tseslint.configs.disableTypeChecked, + rules: { + ...tseslint.configs.disableTypeChecked.rules, + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + }, + }, + /* .mjs files — no TypeScript annotations, add Node.js globals */ + { + files: ["**/*.mjs"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + }, + }, + rules: { + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + }, + }, +) diff --git a/Agent-backend/learnings.md b/Agent-backend/learnings.md new file mode 100644 index 000000000..634bb1fc5 --- /dev/null +++ b/Agent-backend/learnings.md @@ -0,0 +1,222 @@ +# OpenCode TUI — Learnings & Debugging Reference + +> **Path:** `Agent-backend/packages/opencode/` +> **Last Updated:** March 10, 2026 + +--- + +## How to Debug the TUI + +The TUI takes over the terminal, so `console.log` output is invisible. Here's what works: + +### Debug Logging (built-in) + +```bash +# Run with debug logs printed to stderr, redirect to a file +bun dev --print-logs 2>>/tmp/opencode-debug.log + +# Use the TUI normally, trigger the bug, then quit (Ctrl+C) +# Read the log: +cat /tmp/opencode-debug.log +``` + +The actual log file is also at `~/.local/share/orbit/log/dev.log` — this captures ALL log entries regardless of `--print-logs`. Grep it for errors: + +```bash +grep -i "ERROR\|WARN" ~/.local/share/orbit/log/dev.log | tail -30 +``` + +### Bun Inspector (breakpoints) + +```bash +bun run --inspect=ws://localhost:6499/ dev +# Or set it permanently: +export BUN_OPTIONS=--inspect=ws://localhost:6499/ +``` + +**Known limitation:** `.tsx` files won't have correct breakpoint mapping due to Bun not supporting source maps on BunPlugin-transformed code. Workaround: insert `debugger;` statements directly in the code. + +For server code breakpoints while TUI runs, use `bun dev spawn` (default runs server in a worker thread where breakpoints may not trigger). + +### CLI Flags + +| Flag | Purpose | +| ------------------- | -------------------------------------------- | +| `--print-logs` | Print structured logs to stderr | +| `--log-level DEBUG` | Set log verbosity (DEBUG, INFO, WARN, ERROR) | + +**WARNING:** `-d` is NOT a valid flag. It will crash with `Unknown argument: d` — but the error is swallowed by yargs `.fail()` handler (throws `undefined` because yargs passes `(msg, err)` where `err` is undefined for validation errors). + +### Error Swallowing in index.ts + +The top-level catch in `src/index.ts` (line ~166) catches all errors. The `.fail()` handler at line ~159 throws `err` which can be `undefined` for yargs validation errors, producing the cryptic "Unexpected error, check log file at for more details / undefined" output. If you need to debug startup crashes, temporarily add `console.error("DEBUG:", e)` in the catch block. + +--- + +## Bugs Found & Fixed + +### 1. `ERR_INVALID_URL: fetch() URL is invalid` (when sending a message) + +**Root cause:** The `opencode-anthropic-auth` plugin intercepts all `fetch()` calls. The Anthropic provider in `models.dev` has NO `api` URL field (Anthropic's SDK has it hardcoded). The model resolution at `src/provider/provider.ts:751` falls through to `""` (empty string): + +```typescript +url: model.provider?.api ?? provider.api ?? "" +``` + +Then `loadBaseURL()` returns `""`, which gets set as `options.baseURL = ""`, overriding `@ai-sdk/anthropic`'s default `https://api.anthropic.com/v1`. The SDK then constructs a URL like `/v1/messages` which is invalid for `fetch()`. + +**Fix:** In `loadBaseURL()` (line 106), treat empty string as undefined: + +```typescript +if (typeof raw !== "string" || raw === "") return raw === "" ? undefined : raw +``` + +This lets bundled SDKs (Anthropic, OpenAI, etc.) use their hardcoded default URLs. + +**Stack trace pattern:** + +``` +service=session.processor error=fetch() URL is invalid +stack="TypeError: fetch() URL is invalid + at fetch (unknown) + at fetch (.../opencode-anthropic-auth/index.mjs:269:38)" +``` + +### 2. `undefined is not an object (evaluating 'x.keybind')` (dialog-command.tsx) + +**Root cause:** During SolidJS reactivity cascades (theme initialization), `registrations().flatMap((x) => x())` produced undefined entries because some registration accessors return undefined before their components fully initialize. + +**Fix:** Added `.filter(Boolean)` before `.map()` in the `entries` memo: + +```typescript +const entries = createMemo(() => { + const all = registrations() + .flatMap((x) => x()) + .filter(Boolean) + return all.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined })) +}) +``` + +**File:** `src/cli/cmd/tui/component/dialog-command.tsx:56` + +### 3. `undefined is not an object (evaluating 'status().type')` (prompt/index.tsx) + +**Root cause:** On the home screen, `props.sessionID` is `undefined`. The status lookup `sync.data.session_status[props.sessionID ?? ""]` returns `undefined` because `session_status[""]` doesn't exist. + +**Fix:** Added fallback: + +```typescript +const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" as const }) +``` + +**File:** `src/cli/cmd/tui/component/prompt/index.tsx:81` + +### 4. `undefined is not an object (evaluating 'grouped().length')` (dialog-select.tsx) + +**Root cause:** In `DialogCommand`, the `ref` for `DialogSelectRef` is set via a callback at render time (line 228), but the `list()` function accesses `ref.filter` during memo evaluation — before the callback runs. + +**Fix:** Made `ref` optional and used optional chaining: + +```typescript +let ref: DialogSelectRef | undefined +const list = (): CommandOption[] => { + if (ref?.filter) return props.options + return [...props.suggestedOptions, ...props.options] +} +``` + +**File:** `src/cli/cmd/tui/component/dialog-command.tsx:161-163` + +### 5. TUI screen blink on stream completion (intermittent, UNRESOLVED) + +**Symptom:** When LLM streaming ends, the entire screen briefly goes blank then reappears. Happens intermittently, not every time. The loading bar under the chat input disappears and gets replaced by the response duration — during that transition, the screen flashes. + +**Suspected cause:** Race condition between multiple SSE events firing in quick succession when streaming ends: + +- `message.updated` (sets `time.completed`) +- `session.status` (transitions to `idle`) +- `message.part.updated` (final part update) + +The `reconcile()` call in `sync.tsx:229` for `message.updated` triggers re-evaluation of `pending()`, `final()`, `lastAssistant()` memos, plus the `` in prompt/index.tsx:1082 switches from spinner to empty ``. When these all fire in rapid succession, opentui's terminal renderer may produce a visible flash between frames. + +**Key components in the render chain:** + +- `sync.tsx:217` — `session.status` event handler +- `sync.tsx:229` — `message.updated` with `reconcile()` +- `session/index.tsx:164-166` — `pending` memo (assistant with `time.completed === 0`) +- `session/index.tsx:1516-1541` — `Switch/Match` for message footer (duration display) +- `prompt/index.tsx:1082` — `` (spinner/status bar) + +--- + +## SolidJS TUI Gotchas + +### Don't manually call function elements in JSX + +SolidJS natively handles `() => JSX.Element` in JSX children as reactive computations. If you change rendering from: + +```tsx +{ + value.stack.at(-1)?.element +} +``` + +to: + +```tsx +{ + typeof element === "function" ? element() : element +} +``` + +...you **break** SolidJS's reactive context tracking. The function gets called outside the proper reactive scope. + +**Rule:** Let SolidJS handle function-vs-element dispatch. Just pass it through in JSX. + +### `createMemo`/`createSignal`/`createStore` must be in reactive scope + +These primitives MUST be called during component initialization (inside the component function body or inside `createSimpleContext.init()`). Moving them outside breaks SolidJS ownership tracking. + +### `reconcile()` in stores triggers downstream watchers + +`setStore("path", reconcile(newObj))` does a deep diff but can trigger watchers on parent paths. When updating a single message in an array, all memos depending on `messages()` re-evaluate. + +### `.filter(Boolean)` guard for reactive arrays + +Registration-pattern arrays (like `registrations().flatMap(x => x())`) can contain `undefined` during SolidJS reactivity cascades. Always `.filter(Boolean)` before accessing properties. + +--- + +## Architecture Notes + +### Message Flow (streaming) + +``` +LLM Provider → session/processor.ts → Bus events → sync.tsx (SSE) → SolidJS store → in session/index.tsx +``` + +Events during streaming: + +- `message.part.delta` — text chunks (appended via `produce`) +- `message.part.updated` — part metadata updates (via `reconcile`) +- `message.updated` — message metadata (via `reconcile`) + +Events at stream end: + +- `message.updated` with `time.completed` set +- `session.status` → `{ type: "idle" }` + +### Provider URL Resolution + +``` +models.dev JSON → model.api.url (can be "" for bundled providers) + → loadBaseURL() → options.baseURL + → @ai-sdk/anthropic createAnthropic({ baseURL }) + → fetch(url) via opencode-anthropic-auth interceptor +``` + +Bundled providers (Anthropic, OpenAI, etc.) have hardcoded URLs in their SDKs. Their `models.dev` entries have NO `api` field. The fallback `""` in model resolution MUST be treated as undefined to let the SDK defaults work. + +### Plugin System + +`opencode-anthropic-auth@0.0.13` is a built-in plugin loaded from `src/plugin/index.ts:24`. It's installed to `~/.cache/orbit/node_modules/` at runtime. It wraps `fetch()` to inject OAuth tokens and rename tools with `mcp_` prefix. diff --git a/Agent-backend/package.json b/Agent-backend/package.json new file mode 100644 index 000000000..4bb06ff5e --- /dev/null +++ b/Agent-backend/package.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "opencode", + "description": "AI-powered development tool", + "private": true, + "type": "module", + "packageManager": "bun@1.3.10", + "scripts": { + "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", + "dev:desktop": "bun --cwd packages/desktop tauri dev", + "dev:web": "bun --cwd packages/app dev", + "typecheck": "bun turbo typecheck", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "check": "bun turbo typecheck && bun run lint", + "prepare": "husky", + "test": "echo 'do not run tests from root' && exit 1" + }, + "workspaces": { + "packages": [ + "packages/*", + "packages/sdk/js" + ], + "catalog": { + "@types/bun": "1.3.9", + "@octokit/rest": "22.0.0", + "@hono/zod-validator": "0.4.2", + "ulid": "3.0.1", + "@kobalte/core": "0.13.11", + "@types/luxon": "3.7.1", + "@types/node": "22.13.9", + "@types/semver": "7.7.1", + "@tsconfig/node22": "22.0.2", + "@tsconfig/bun": "1.0.9", + "@cloudflare/workers-types": "4.20251008.0", + "@openauthjs/openauth": "0.0.0-20250322224806", + "@pierre/diffs": "1.1.0-beta.18", + "@solid-primitives/storage": "4.3.3", + "@tailwindcss/vite": "4.1.11", + "diff": "8.0.2", + "dompurify": "3.3.1", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", + "ai": "5.0.124", + "hono": "4.10.7", + "hono-openapi": "1.1.2", + "fuzzysort": "3.1.0", + "luxon": "3.6.1", + "marked": "17.0.1", + "marked-shiki": "1.2.1", + "@playwright/test": "1.51.0", + "typescript": "5.8.2", + "@typescript/native-preview": "7.0.0-dev.20251207.1", + "zod": "4.1.8", + "remeda": "2.26.0", + "shiki": "3.20.0", + "solid-list": "0.3.0", + "tailwindcss": "4.1.11", + "virtua": "0.42.3", + "vite": "7.1.4", + "@solidjs/meta": "0.29.4", + "@solidjs/router": "0.15.4", + "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "solid-js": "1.9.10", + "vite-plugin-solid": "2.11.10" + } + }, + "devDependencies": { + "@actions/artifact": "5.0.1", + "@eslint/js": "10.0.1", + "@tsconfig/bun": "catalog:", + "@types/mime-types": "3.0.1", + "@typescript/native-preview": "catalog:", + "eslint": "10.0.3", + "eslint-plugin-import-x": "4.16.1", + "glob": "13.0.5", + "husky": "9.1.7", + "prettier": "3.6.2", + "semver": "^7.6.0", + "sst": "3.18.10", + "turbo": "2.8.13", + "typescript-eslint": "8.57.0" + }, + "dependencies": { + "@aws-sdk/client-s3": "3.933.0", + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/script": "workspace:*", + "@opencode-ai/sdk": "workspace:*", + "typescript": "catalog:" + }, + "repository": { + "type": "git", + "url": "https://github.com/anomalyco/opencode" + }, + "license": "MIT", + "prettier": { + "semi": false, + "printWidth": 120 + }, + "trustedDependencies": [ + "esbuild", + "protobufjs", + "tree-sitter", + "tree-sitter-bash", + "web-tree-sitter", + "electron" + ], + "overrides": { + "@types/bun": "catalog:", + "@types/node": "catalog:" + }, + "patchedDependencies": { + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch" + } +} diff --git a/Agent-backend/packages/CLAUDE.md b/Agent-backend/packages/CLAUDE.md new file mode 100644 index 000000000..1c592ab8e --- /dev/null +++ b/Agent-backend/packages/CLAUDE.md @@ -0,0 +1,89 @@ +# packages + +> **Path:** `Agent-backend/packages/` + +## Purpose + +Bun workspace monorepo containing the OpenCode engine and reference packages. Orchestrated with Turbo (`bun turbo `). Stripped down from the full upstream monorepo — upstream product infrastructure (console, marketing site, Slack bot, Storybook, etc.) has been removed. What remains is the engine plus reference code for studying data flows and UI patterns. + +## Usage Status — At a Glance + +``` +██ active ░░ reference + +Orbit: ██ opencode ██ sdk ██ plugin ██ util ██ script ░░ app ░░ desktop ░░ ui +``` + +## Package Map + +### Engine (actively used by Orbit) + +| Package | Name | Purpose | +| ----------- | --------------------- | -------------------------------------------------------------------------------------------------------------- | +| `opencode/` | `opencode` | CLI, API server, agent orchestration, 30+ tools, 20+ LLM providers, MCP, sessions, permissions. **The heart.** | +| `sdk/js/` | `@opencode-ai/sdk` | Auto-generated TypeScript API client from `sdk/openapi.json`. Already rebranded (`OrbitClient`). | +| `plugin/` | `@opencode-ai/plugin` | Plugin SDK — `tool()` factory, 20+ hook points, auth hooks | +| `util/` | `@opencode-ai/util` | Shared utilities (array, encoding, error, path, retry, slug) | +| `script/` | `@opencode-ai/script` | Build script utilities (semver) | + +### Reference (kept for patterns, not run) + +| Package | Name | Purpose | +| ---------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- | +| `app/` | `@opencode-ai/app` | SolidJS web UI — context providers, SDK data fetching, SSE event handling, component patterns | +| `desktop/` | `@opencode-ai/desktop` | Tauri v2 desktop app — sidecar lifecycle, native platform abstraction, auto-updater, window chrome | +| `ui/` | `@opencode-ai/ui` | 175 SolidJS components — theme system, provider icons, audio assets. Keeps `app/` import paths resolvable. | + +### Removed (upstream infrastructure) + +The following were removed during the Strip-to-SDK audit. See `docs/plans/STRIP-TO-SDK-AUDIT.md` for full details. + +`console/` (5 sub-packages), `containers/`, `desktop-electron/`, `docs/`, `enterprise/`, `extensions/`, `function/`, `identity/`, `slack/`, `storybook/`, `web/` + +## Dependency Flow + +``` +util, sdk/js, plugin, script (foundational — no internal deps) + ↓ +opencode, ui (core engine + component library) + ↓ +app (web UI — depends on ui + sdk) + ↓ +desktop (wraps app in Tauri + bundles opencode CLI as sidecar) +``` + +## Common Commands + +```bash +# From repo root +bun turbo typecheck # Type-check all packages +bun turbo build # Build all packages + +# Engine dev +bun dev # CLI (TUI mode) +bun dev serve # Headless API server (port 4096) + +# Reference app dev servers +bun run --cwd packages/app dev # Web UI (needs server running) + +# Tests (never from repo root — guarded by bunfig.toml) +cd packages/opencode && bun test --timeout 30000 + +# SDK regeneration (after changing server/API code) +./script/generate.ts +``` + +## Key Conventions + +- All UI uses **SolidJS** (not React) — Orbit's React frontend does NOT import these directly +- Database access uses **Drizzle ORM** — SQLite in `opencode` +- The `ui` package has extensive conditional exports — import from subpaths like `@opencode-ai/ui/hooks` + +## Development Guide + +For Orbit work, focus almost exclusively on `packages/opencode/`. When adding new server endpoints, work in `packages/opencode/src/server/`. When modifying tools, work in `packages/opencode/src/tool/`. The `app/` and `desktop/` packages are read-only reference — study their patterns but don't modify them. + +## Notes + +- **SolidJS vs React:** All upstream UI packages use SolidJS. Orbit's desktop app uses React. There is zero UI code sharing — we only share the engine (`opencode`) and consume it via HTTP + SDK. +- **Already rebranded:** `packages/opencode` has `scriptName("orbit")` and `"orbit": "./bin/opencode"`. `packages/sdk` exports `OrbitClient` and `createOrbitClient()`. diff --git a/Agent-backend/packages/app/.gitignore b/Agent-backend/packages/app/.gitignore new file mode 100644 index 000000000..d699efb38 --- /dev/null +++ b/Agent-backend/packages/app/.gitignore @@ -0,0 +1,3 @@ +src/assets/theme.css +e2e/test-results +e2e/playwright-report diff --git a/Agent-backend/packages/app/AGENTS.md b/Agent-backend/packages/app/AGENTS.md new file mode 100644 index 000000000..765e960c8 --- /dev/null +++ b/Agent-backend/packages/app/AGENTS.md @@ -0,0 +1,30 @@ +## Debugging + +- NEVER try to restart the app, or the server process, EVER. + +## Local Dev + +- `opencode dev web` proxies `https://app.opencode.ai`, so local UI/CSS changes will not show there. +- For local UI changes, run the backend and app dev servers separately. +- Backend (from `packages/opencode`): `bun run --conditions=browser ./src/index.ts serve --port 4096` +- App (from `packages/app`): `bun dev -- --port 4444` +- Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`). + +## SolidJS + +- Always prefer `createStore` over multiple `createSignal` calls + +## Tool Calling + +- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. + +## Browser Automation + +Use `agent-browser` for web automation. Run `agent-browser --help` for all commands. + +Core workflow: + +1. `agent-browser open ` - Navigate to page +2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2) +3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs +4. Re-snapshot after page changes diff --git a/Agent-backend/packages/app/CLAUDE.md b/Agent-backend/packages/app/CLAUDE.md new file mode 100644 index 000000000..d85e61c95 --- /dev/null +++ b/Agent-backend/packages/app/CLAUDE.md @@ -0,0 +1,144 @@ +# packages/app + +> **Path:** `Agent-backend/packages/app/` + +## Purpose + +The OpenCode web UI — a SolidJS single-page application that connects to the headless OpenCode server via HTTP + SSE. Provides the full chat interface, file tree, terminal, settings, model picker, command palette, session management, and all visual tools. Also hosts the Playwright E2E test suite. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Orbit's React UI reimplements the same features. The context provider architecture, component structure, and E2E test patterns are the primary reference. SolidJS code is not directly reusable — only the patterns, data flows, and UI logic translate. | +| Orbit CLI | `rebuild` | CLI TUI lives in `packages/opencode`, but this app's routing, settings persistence, and i18n patterns would inform a future web CLI mode | + +## Key Files + +| File | Purpose | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `src/entry.tsx` | Web entry point — creates `Platform` object (web), renders `AppInterface` with HTTP server connection | +| `src/app.tsx` | Root component — 15+ nested providers, lazy-loaded routes (`/` → Home, `/:slug/session/:id` → Session), error boundary | +| `src/index.css` | Global styles (Tailwind imports + theme variables) | +| `src/index.ts` | Package exports for consumption by `packages/desktop` | +| `package.json` | `@opencode-ai/app` — SolidJS + Vite + Tailwind + Shiki + Ghostty-web | +| `vite.config.ts` | Vite config — port 3000, ESNext target | +| `playwright.config.ts` | E2E test configuration | +| `AGENTS.md` | Dev guide — local setup, SolidJS patterns, browser automation | +| `happydom.ts` | HappyDOM preload for unit tests (`bun test --preload ./happydom.ts`) | + +## Source Structure + +### `src/components/` — UI Components (37 files) + +| Category | Files | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Dialogs** | `dialog-connect-provider`, `dialog-custom-provider`, `dialog-edit-project`, `dialog-fork`, `dialog-manage-models`, `dialog-release-notes`, `dialog-select-directory`, `dialog-select-file`, `dialog-select-mcp`, `dialog-select-model`, `dialog-select-model-unpaid`, `dialog-select-provider`, `dialog-select-server` | +| **Settings tabs** | `settings-agents`, `settings-commands`, `settings-general`, `settings-keybinds`, `settings-mcp`, `settings-models`, `settings-permissions`, `settings-providers` | +| **Core** | `prompt-input/` (subdirectory), `prompt-input.tsx`, `terminal.tsx`, `file-tree.tsx`, `titlebar.tsx`, `titlebar-history.ts`, `status-popover.tsx`, `debug-bar.tsx`, `link.tsx`, `model-tooltip.tsx`, `session-context-usage.tsx` | +| **Session** | `session/` (subdirectory), `server/` (subdirectory) | + +### `src/context/` — SolidJS Context Providers (37 files) + +Every major domain has a dedicated context provider: + +| Context | Purpose | +| ----------------------------------------------- | ------------------------------------------------------------- | +| `sdk.tsx` / `global-sdk.tsx` | SDK client instance + global SDK for cross-session operations | +| `server.tsx` | Server connection management (HTTP type) | +| `sync.tsx` / `global-sync.tsx` | SSE subscriptions for real-time updates | +| `command.tsx` | Command palette + keybinding system | +| `file.tsx` + `file/` | File tree state + content eviction | +| `layout.tsx` + `layout-scroll.ts` | Panel layout + scroll position persistence | +| `settings.tsx` | Settings persistence (`settings.v3` localStorage) | +| `terminal.tsx` | Terminal tab state + workspace-scoped persistence | +| `prompt.tsx` | Prompt input state, history, mentions | +| `permission.tsx` + `permission-auto-respond.ts` | Permission request handling + auto-accept | +| `models.tsx` + `model-variant.ts` | Model selection, visibility, variants | +| `language.tsx` | i18n with English + Chinese locales | +| `notification.tsx` | Browser notifications (agent complete, permissions, errors) | +| `comments.tsx` | Code review comments | +| `highlights.tsx` | Syntax highlighting via Shiki | +| `local.tsx` | Local-only state | +| `platform.tsx` | Platform abstraction (web vs desktop) | + +### `src/pages/` — Route Pages + +| Page | Route | +| ------------------------ | ----------------------------------------- | +| `home.tsx` | `/` — project list, new session | +| `session.tsx` | `/:slug/session/:id` — chat view | +| `layout.tsx` + `layout/` | Shared layout (sidebar, titlebar, panels) | +| `directory-layout.tsx` | Per-directory layout wrapper | +| `error.tsx` | Error boundary fallback | + +### `src/utils/` — Utilities (32 files) + +Server health monitoring, notification click handling, terminal writer, persistence helpers, base64 encoding, DOM utilities, UUID generation, speech-to-text, sound playback, drag-and-drop (solid-dnd), server error classification, scoped caching, worktree helpers, time formatting. + +### `src/addons/` — Serialization + +`serialize.ts` + `serialize.test.ts` — custom serialization for terminal state and other persisted data. + +### `src/hooks/` — Custom Hooks + +`use-providers.ts` — provider list hook. + +### `src/i18n/` — Internationalization + +18 locale files (en, zh, de, fr, es, ja, ko, pt, ru, ar, and more). Dictionary-based with `@solid-primitives/i18n`. + +## Commands + +```bash +# Development (standalone — needs backend server running separately) +bun dev # Vite dev server, port 3000 +bun dev -- --port 4444 # Custom port + +# Backend for local dev (from packages/opencode) +bun run --conditions=browser ./src/index.ts serve --port 4096 + +# Build +bun run build # Vite production build +bun run serve # Preview production build + +# Type checking +bun run typecheck # tsgo -b (TypeScript Go compiler) + +# Unit tests +bun run test # HappyDOM + Bun test runner +bun run test:unit:watch # Watch mode + +# E2E tests +bun run test:e2e # Playwright (needs running server) +bun run test:e2e:local # Full local setup via script +bun run test:e2e:ui # Playwright interactive UI +bun run test:e2e:report # View HTML report +``` + +## Dependencies + +- **Internal:** `@opencode-ai/sdk` (API client), `@opencode-ai/ui` (component library), `@opencode-ai/util` (utilities) +- **SolidJS ecosystem:** `solid-js`, `@solidjs/router`, `@solidjs/meta`, `@kobalte/core`, `solid-list`, `virtua` (virtual scrolling), `@thisbeyond/solid-dnd` +- **Terminal:** `ghostty-web` (Ghostty terminal emulator compiled to WASM) +- **Code:** `shiki` + `@shikijs/transformers` (syntax highlighting), `marked` + `marked-shiki` (Markdown rendering) +- **Utilities:** `luxon` (dates), `fuzzysort` (search), `remeda` (FP utilities), `diff` (text diffing), `zod` (validation) + +## Development Guide + +For Orbit development, this package is purely **reference** — don't modify it. Study: + +- **Context provider architecture** — how each domain (files, terminal, settings, permissions) is isolated into its own provider with dedicated state +- **E2E test patterns** — `e2e/` directory has complete test infrastructure (see `e2e/CLAUDE.md`) +- **Platform abstraction** — `Platform` type in `context/platform.tsx` abstracts web vs desktop (notify, openLink, back/forward) +- **Settings persistence** — `settings.v3` localStorage key with `@solid-primitives/storage` +- **Terminal integration** — Ghostty WASM terminal with workspace-scoped buffer persistence + +## Notes + +- **SolidJS, not React** — all components use SolidJS signals, stores, and context. `createStore` preferred over multiple `createSignal` calls (per AGENTS.md). No React hooks or JSX patterns apply here. +- **15+ nested providers in `app.tsx`** — the provider chain is: Platform → Language → I18n → Theme → Font → Meta → Dialog → File → Server → SDK → Settings → GlobalSDK → GlobalSync → Layout → Models → Permission → Notification → Command → Comments → Highlights → Prompt → Terminal → Router. Order matters. +- **Ghostty WASM terminal** — `ghostty-web` is a WASM build of the Ghostty terminal emulator, loaded from `github:anomalyco/ghostty-web#main`. This is the same terminal used in the E2E tests. +- **`tsgo -b` for typechecking** — uses TypeScript's native Go-based compiler (`@typescript/native-preview`) for faster type checking than standard `tsc`. +- **HappyDOM for unit tests** — `happydom.ts` preload registers DOM globals so SolidJS components can be tested outside a browser. Tests use `bun test` (not Vitest). +- **The `Platform` abstraction** is key for Orbit — `entry.tsx` creates a web platform, while `packages/desktop` creates a Tauri platform with different implementations for `notify`, `openLink`, `back`, `forward`, `restart`, and `getDefaultServerUrl`. diff --git a/Agent-backend/packages/app/README.md b/Agent-backend/packages/app/README.md new file mode 100644 index 000000000..54d1b2861 --- /dev/null +++ b/Agent-backend/packages/app/README.md @@ -0,0 +1,51 @@ +## Usage + +Dependencies for these templates are managed with [pnpm](https://pnpm.io) using `pnpm up -Lri`. + +This is the reason you see a `pnpm-lock.yaml`. That said, any package manager will work. This file can safely be removed once you clone a template. + +```bash +$ npm install # or pnpm install or yarn install +``` + +### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) + +## Available Scripts + +In the project directory, you can run: + +### `npm run dev` or `npm start` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+ +### `npm run build` + +Builds the app for production to the `dist` folder.
+It correctly bundles Solid in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +## E2E Testing + +Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`). +Use the local runner to create a temp sandbox, seed data, and run the tests. + +```bash +bunx playwright install +bun run test:e2e:local +bun run test:e2e:local -- --grep "settings" +``` + +Environment options: + +- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`) +- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`) +- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:`) + +## Deployment + +You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/Agent-backend/packages/app/bunfig.toml b/Agent-backend/packages/app/bunfig.toml new file mode 100644 index 000000000..f1caabbcc --- /dev/null +++ b/Agent-backend/packages/app/bunfig.toml @@ -0,0 +1,3 @@ +[test] +root = "./src" +preload = ["./happydom.ts"] diff --git a/Agent-backend/packages/app/happydom.ts b/Agent-backend/packages/app/happydom.ts new file mode 100644 index 000000000..de726718f --- /dev/null +++ b/Agent-backend/packages/app/happydom.ts @@ -0,0 +1,75 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator" + +GlobalRegistrator.register() + +const originalGetContext = HTMLCanvasElement.prototype.getContext +// @ts-expect-error - we're overriding with a simplified mock +HTMLCanvasElement.prototype.getContext = function (contextType: string, _options?: unknown) { + if (contextType === "2d") { + return { + canvas: this, + fillStyle: "#000000", + strokeStyle: "#000000", + font: "12px monospace", + textAlign: "start", + textBaseline: "alphabetic", + globalAlpha: 1, + globalCompositeOperation: "source-over", + imageSmoothingEnabled: true, + lineWidth: 1, + lineCap: "butt", + lineJoin: "miter", + miterLimit: 10, + shadowBlur: 0, + shadowColor: "rgba(0, 0, 0, 0)", + shadowOffsetX: 0, + shadowOffsetY: 0, + fillRect: () => {}, + strokeRect: () => {}, + clearRect: () => {}, + fillText: () => {}, + strokeText: () => {}, + measureText: (text: string) => ({ width: text.length * 8 }), + drawImage: () => {}, + save: () => {}, + restore: () => {}, + scale: () => {}, + rotate: () => {}, + translate: () => {}, + transform: () => {}, + setTransform: () => {}, + resetTransform: () => {}, + createLinearGradient: () => ({ addColorStop: () => {} }), + createRadialGradient: () => ({ addColorStop: () => {} }), + createPattern: () => null, + beginPath: () => {}, + closePath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + bezierCurveTo: () => {}, + quadraticCurveTo: () => {}, + arc: () => {}, + arcTo: () => {}, + ellipse: () => {}, + rect: () => {}, + fill: () => {}, + stroke: () => {}, + clip: () => {}, + isPointInPath: () => false, + isPointInStroke: () => false, + getTransform: () => ({}), + getImageData: () => ({ + data: new Uint8ClampedArray(0), + width: 0, + height: 0, + }), + putImageData: () => {}, + createImageData: () => ({ + data: new Uint8ClampedArray(0), + width: 0, + height: 0, + }), + } as unknown as CanvasRenderingContext2D + } + return originalGetContext.call(this, contextType as "2d", _options) +} diff --git a/Agent-backend/packages/app/index.html b/Agent-backend/packages/app/index.html new file mode 100644 index 000000000..6fa345535 --- /dev/null +++ b/Agent-backend/packages/app/index.html @@ -0,0 +1,23 @@ + + + + + + OpenCode + + + + + + + + + + + + + +
+ + + diff --git a/Agent-backend/packages/app/package.json b/Agent-backend/packages/app/package.json new file mode 100644 index 000000000..10ef17d1b --- /dev/null +++ b/Agent-backend/packages/app/package.json @@ -0,0 +1,72 @@ +{ + "name": "@opencode-ai/app", + "version": "1.2.24", + "description": "", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./vite": "./vite.js", + "./index.css": "./src/index.css" + }, + "scripts": { + "typecheck": "tsgo -b", + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "test": "bun run test:unit", + "test:unit": "bun test --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", + "test:e2e": "playwright test", + "test:e2e:local": "bun script/e2e-local.ts", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report e2e/playwright-report" + }, + "license": "MIT", + "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", + "@playwright/test": "1.57.0", + "@tailwindcss/vite": "catalog:", + "@tsconfig/bun": "1.0.9", + "@types/bun": "catalog:", + "@types/luxon": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-icons-spritesheet": "3.0.1", + "vite-plugin-solid": "catalog:" + }, + "dependencies": { + "@kobalte/core": "catalog:", + "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@opencode-ai/util": "workspace:*", + "@shikijs/transformers": "3.9.2", + "@solid-primitives/active-element": "2.1.3", + "@solid-primitives/audio": "1.4.2", + "@solid-primitives/i18n": "2.2.1", + "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/media": "2.3.3", + "@solid-primitives/resize-observer": "2.1.3", + "@solid-primitives/scroll": "2.1.3", + "@solid-primitives/storage": "catalog:", + "@solid-primitives/websocket": "1.3.1", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "@thisbeyond/solid-dnd": "0.7.5", + "diff": "catalog:", + "fuzzysort": "catalog:", + "ghostty-web": "github:anomalyco/ghostty-web#main", + "luxon": "catalog:", + "marked": "catalog:", + "marked-shiki": "catalog:", + "remeda": "catalog:", + "shiki": "catalog:", + "solid-js": "catalog:", + "solid-list": "catalog:", + "tailwindcss": "catalog:", + "virtua": "catalog:", + "zod": "catalog:" + } +} diff --git a/Agent-backend/packages/app/src/CLAUDE.md b/Agent-backend/packages/app/src/CLAUDE.md new file mode 100644 index 000000000..5433a6c56 --- /dev/null +++ b/Agent-backend/packages/app/src/CLAUDE.md @@ -0,0 +1,39 @@ +# src + +> **Path:** `Agent-backend/packages/app/src/` + +## Purpose + +Source root for the OpenCode web UI application. Contains the SolidJS app entry point, root component with 15+ nested context providers, lazy-loaded route pages, and all subdirectories for components, context providers, pages, hooks, utilities, i18n, and addons. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Architecture patterns (provider nesting, platform abstraction, context isolation) are the main takeaways. SolidJS code is not directly reusable in Orbit's React frontend. | +| Orbit CLI | `reference` | Same | + +## Files + +| File | Purpose | +| ----------------------- | -------------------------------------------------------------------------------------------------------- | +| `entry.tsx` | Web entry point — creates `Platform` object (web), renders `AppInterface` | +| `app.tsx` | Root component — 15+ nested providers, lazy-loaded routes (`/` and `/:slug/session/:id`), error boundary | +| `index.css` | Global Tailwind imports + theme CSS variables | +| `index.ts` | Package exports for consumption by `packages/desktop` | +| `env.d.ts` | Vite environment type declarations | +| `sst-env.d.ts` | SST environment type declarations | +| `custom-elements.d.ts` | Custom element types (symlink to `ui/src/`) | +| `theme-preload.test.ts` | Tests for theme preload logic | + +## Subdirectories + +| Directory | Purpose | +| ------------- | ------------------------------------------------------------------------------------------- | +| `components/` | UI components — dialogs, settings tabs, prompt input, session, terminal, file tree | +| `context/` | SolidJS context providers — SDK, sync, settings, files, terminal, permissions, models, i18n | +| `pages/` | Route pages — home, session, layout, error | +| `hooks/` | Custom hooks — `use-providers.ts` | +| `utils/` | 32 utility modules — health monitoring, persistence, base64, DnD, speech-to-text, sounds | +| `i18n/` | 18 locale files (en, zh, de, fr, es, ja, ko, etc.) | +| `addons/` | Serialization utilities for terminal state persistence | diff --git a/Agent-backend/packages/app/src/app.tsx b/Agent-backend/packages/app/src/app.tsx new file mode 100644 index 000000000..52a1dac6a --- /dev/null +++ b/Agent-backend/packages/app/src/app.tsx @@ -0,0 +1,170 @@ +import "@/index.css" +import { File } from "@opencode-ai/ui/file" +import { I18nProvider } from "@opencode-ai/ui/context" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { FileComponentProvider } from "@opencode-ai/ui/context/file" +import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import { Font } from "@opencode-ai/ui/font" +import { ThemeProvider } from "@opencode-ai/ui/theme" +import { MetaProvider } from "@solidjs/meta" +import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js" +import { CommandProvider } from "@/context/command" +import { CommentsProvider } from "@/context/comments" +import { FileProvider } from "@/context/file" +import { GlobalSDKProvider } from "@/context/global-sdk" +import { GlobalSyncProvider } from "@/context/global-sync" +import { HighlightsProvider } from "@/context/highlights" +import { LanguageProvider, useLanguage } from "@/context/language" +import { LayoutProvider } from "@/context/layout" +import { ModelsProvider } from "@/context/models" +import { NotificationProvider } from "@/context/notification" +import { PermissionProvider } from "@/context/permission" +import { usePlatform } from "@/context/platform" +import { PromptProvider } from "@/context/prompt" +import { type ServerConnection, ServerProvider, useServer } from "@/context/server" +import { SettingsProvider } from "@/context/settings" +import { TerminalProvider } from "@/context/terminal" +import DirectoryLayout from "@/pages/directory-layout" +import Layout from "@/pages/layout" +import { ErrorPage } from "./pages/error" +import { Dynamic } from "solid-js/web" + +const Home = lazy(() => import("@/pages/home")) +const Session = lazy(() => import("@/pages/session")) +const Loading = () =>
+ +const HomeRoute = () => ( + }> + + +) + +const SessionRoute = () => ( + + }> + + + +) + +const SessionIndexRoute = () => + +function UiI18nBridge(props: ParentProps) { + const language = useLanguage() + return {props.children} +} + +declare global { + interface Window { + __OPENCODE__?: { + updaterEnabled?: boolean + deepLinks?: string[] + wsl?: boolean + } + } +} + +function MarkedProviderWithNativeParser(props: ParentProps) { + const platform = usePlatform() + return {props.children} +} + +function AppShellProviders(props: ParentProps) { + return ( + + + + + + + + {props.children} + + + + + + + + ) +} + +function SessionProviders(props: ParentProps) { + return ( + + + + {props.children} + + + + ) +} + +function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { + return ( + + {props.appChildren} + {props.children} + + ) +} + +export function AppBaseProviders(props: ParentProps) { + return ( + + + + + + }> + + + {props.children} + + + + + + + + ) +} + +function ServerKey(props: ParentProps) { + const server = useServer() + return ( + + {props.children} + + ) +} + +export function AppInterface(props: { + children?: JSX.Element + defaultServer: ServerConnection.Key + servers?: Array + router?: Component +}) { + return ( + + + + + {routerProps.children}} + > + + + + + + + + + + + ) +} diff --git a/Agent-backend/packages/app/src/components/CLAUDE.md b/Agent-backend/packages/app/src/components/CLAUDE.md new file mode 100644 index 000000000..22fd9bfcc --- /dev/null +++ b/Agent-backend/packages/app/src/components/CLAUDE.md @@ -0,0 +1,134 @@ +# components + +> **Path:** `Agent-backend/packages/app/src/components/` + +## Purpose + +All SolidJS UI components for the OpenCode web app. Flat structure with three subdirectories (`prompt-input/`, `session/`, `server/`). Contains dialogs, settings tabs, the prompt input system, terminal wrapper, session header, file tree, status popover, titlebar, and debug bar. Components depend heavily on the `@/context/` providers and `@opencode-ai/ui` component library (Kobalte). + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Orbit Desktop (SDK) | `reference` | SolidJS code doesn't directly port, but the component architecture, data flows, state management patterns, and UI logic are the primary reference for Orbit's React reimplementation | +| Orbit CLI | `reference` | Same | + +## File Inventory + +### Dialogs (13 files) + +| File | Purpose | +| -------------------------------- | ------------------------------------------------------------- | +| `dialog-connect-provider.tsx` | OAuth/API key flow for connecting a new LLM provider | +| `dialog-custom-provider.tsx` | Custom provider configuration (endpoint, API key, model list) | +| `dialog-edit-project.tsx` | Edit project settings (workspace, environment) | +| `dialog-fork.tsx` | Fork/branch a session conversation | +| `dialog-manage-models.tsx` | Manage visible models in the model picker | +| `dialog-release-notes.tsx` | Display release notes on version update | +| `dialog-select-directory.tsx` | Directory picker with file-system browsing | +| `dialog-select-file.tsx` | File picker with search and preview | +| `dialog-select-mcp.tsx` | MCP server selection | +| `dialog-select-model.tsx` | Model picker popover with provider grouping | +| `dialog-select-model-unpaid.tsx` | Model selection with unpaid/upgrade prompt | +| `dialog-select-provider.tsx` | Provider selection from configured list | +| `dialog-select-server.tsx` | Server connection picker (local, remote, mDNS-discovered) | + +### Settings Tabs (8 files) + +| File | Purpose | +| -------------------------- | ------------------------------------------------------------ | +| `settings-general.tsx` | General preferences (theme, font, telemetry, update channel) | +| `settings-keybinds.tsx` | Keybinding editor with conflict detection | +| `settings-models.tsx` | Model visibility and default selection | +| `settings-providers.tsx` | Provider configuration and credentials | +| `settings-permissions.tsx` | Tool permission rules | +| `settings-mcp.tsx` | MCP server configuration | +| `settings-agents.tsx` | Agent configuration (build, plan) | +| `settings-commands.tsx` | Slash command configuration | +| `dialog-settings.tsx` | Settings dialog container (hosts tab components) | + +### Core Components (9 files) + +| File | Purpose | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `prompt-input.tsx` | **Largest component (52KB)** — the chat input area. ContentEditable editor with `@`-mention popover, `/`-slash commands, image attachments, file drag-and-drop, shell mode toggle, model selector, prompt history navigation, and submit logic. Imports all `prompt-input/` submodules. | +| `terminal.tsx` | Ghostty WASM terminal wrapper. Lazy-loads `ghostty-web`, handles theming (oklch → hex color conversion), keybinding interception, PTY connection via SDK, and serialization for buffer persistence. | +| `file-tree.tsx` | File explorer tree with expand/collapse, file icons, git status indicators, and context menus. Has unit test (`file-tree.test.ts`). | +| `titlebar.tsx` | App titlebar — session tabs, "open in" menu (VS Code, Cursor, Zed, etc.), sidebar toggle, status popover trigger, session management actions | +| `titlebar-history.ts` | Browser-style back/forward navigation state for session history. Has unit test (`titlebar-history.test.ts`). | +| `status-popover.tsx` | 4-tab popover (Servers, MCP, LSP, Plugins) with server health polling, plugin enable/disable toggles, and toast notifications. Uses `ServerRow` from `server/`. | +| `debug-bar.tsx` | Performance monitoring bar showing FPS, memory usage, INP (Interaction to Next Paint), CLS, and route transitions. Uses `PerformanceObserver` API. | +| `link.tsx` | External link component using platform `openLink` abstraction | +| `model-tooltip.tsx` | Model info tooltip with provider, context window, and pricing | +| `session-context-usage.tsx` | Compact context usage indicator (tokens used vs limit) | + +### Subdirectory: `prompt-input/` (15 files) + +Extracted logic modules for the prompt input system. Pure functions + small SolidJS components. + +| File | Purpose | Tests | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `submit.ts` | Submit orchestration — builds request parts, handles session creation, worktree setup, abort controllers, comment attachment, shell mode dispatch | `submit.test.ts` | +| `build-request-parts.ts` | Converts `Prompt` (content parts) into SDK `UserMessagePart[]` with base64-encoded images | `build-request-parts.test.ts` | +| `history.ts` | Prompt history navigation (up/down arrow), max 100 entries, cursor position awareness | `history.test.ts` | +| `editor-dom.ts` | ContentEditable DOM helpers — cursor position get/set, text fragment creation, range edge clamping | `editor-dom.test.ts` | +| `placeholder.ts` | Dynamic placeholder text generator based on context (new session, shell mode, file selected) | `placeholder.test.ts` | +| `attachments.ts` | File attachment processing — drag-and-drop, paste, accepted MIME types | | +| `slash-popover.tsx` | `@`-mention and `/`-slash command popover with fuzzy search and keyboard navigation | | +| `context-items.tsx` | Renders attached context items (files, selections) as removable chips | | +| `image-attachments.tsx` | Image attachment preview grid with remove action | | +| `drag-overlay.tsx` | Drag-and-drop overlay indicator | | + +### Subdirectory: `session/` (10 files) + +Session chrome — header, tabs, context breakdown. + +| File | Purpose | Tests | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `session-header.tsx` | **Second-largest component (29KB)** — session titlebar with model selector, thinking level toggle, "open in" apps menu, share, archive, session actions dropdown. Shows project name, worktree indicator, and context usage bar. | | +| `session-context-tab.tsx` | Full context breakdown panel — accordion per message, raw content viewer, token metrics, breakdown by role (system/user/assistant/tool) with color-coded bars | | +| `session-context-breakdown.ts` | Estimates token breakdown by message role using character-based heuristics | `session-context-breakdown.test.ts` | +| `session-context-metrics.ts` | Computes session context metrics (total tokens, cost estimate, message counts) | `session-context-metrics.test.ts` | +| `session-context-format.ts` | Formats token counts and percentages for display | | +| `session-new-view.tsx` | New session welcome view (greeting, suggested actions) | | +| `session-sortable-tab.tsx` | Draggable session tab with close button, title, and `FileVisual` icon | | +| `session-sortable-terminal-tab.tsx` | Draggable terminal tab with workspace label, close/split actions | | +| `index.ts` | Barrel export of all session components | | + +### Subdirectory: `server/` (1 file) + +| File | Purpose | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `server-row.tsx` | Server connection row with health indicator (dot), name, version, truncation tooltip. Used by `status-popover.tsx`. Exports `ServerHealthIndicator`. | + +## Dependencies + +- **Internal contexts:** Nearly every component imports from `@/context/` (sdk, sync, server, layout, settings, language, platform, command, permission, prompt, file, terminal, comments, highlights, local, models) +- **UI library:** `@opencode-ai/ui` — Button, Icon, IconButton, Popover, Tabs, Select, Tooltip, Switch, Accordion, ScrollView, Markdown, etc. +- **SDK:** `@opencode-ai/sdk/v2/client` for `Message`, `Part`, `UserMessage` types +- **Utilities:** `@opencode-ai/util` (path, encode, array), `@/utils/` (persist, base64, id, server-health, server-errors, runtime-adapters, terminal-writer, worktree, same) +- **SolidJS:** `solid-js`, `solid-js/store`, `solid-js/web`, `@solidjs/router` +- **External:** `ghostty-web` (terminal), `fuzzysort` (search) + +## Development Guide + +For Orbit, study these patterns: + +1. **Prompt input architecture** — the `prompt-input.tsx` + `prompt-input/` split shows how to decompose a complex input component. Pure logic (history, DOM helpers, placeholder, submit) is extracted into testable modules. The main component wires them together with SolidJS reactivity. + +2. **Session header** — demonstrates how to build a dense toolbar with model selection, thinking level cycling, "open in" app launchers, and session lifecycle actions (archive, share, fork, rename). + +3. **Status popover tabs** — shows 4-tab architecture (Servers, MCP, LSP, Plugins) with periodic health polling (`pollMs = 10_000`), reconcile-based store updates, and empty-state messaging. + +4. **Terminal integration** — Ghostty WASM lazy-loading pattern, theme color conversion (oklch → hex via `resolveThemeVariant`), PTY lifecycle management, and serialization for buffer persistence. + +5. **Dialog patterns** — all dialogs use `@opencode-ai/ui` Dialog/Popover primitives from Kobalte. Form state uses `createStore` (SolidJS store), not multiple signals. + +## Notes + +- **SolidJS, not React** — all components use `createSignal`, `createEffect`, `createMemo`, `createStore`, `Show`, `For`, `Switch/Match`. No `useState`, `useEffect`, or React JSX patterns. +- **`prompt-input.tsx` is 52KB** — the single largest component. In Orbit's React reimplementation, consider further decomposition. +- **5 test files** in `prompt-input/` covering submit, build-request-parts, history, editor-dom, and placeholder. Plus `file-tree.test.ts` and `titlebar-history.test.ts` at the component root. Tests use HappyDOM. +- **`session-header.tsx` is 29KB** — the second-largest. Contains the "open in" apps list (VS Code, Cursor, Zed, Ghostty, iTerm2, Warp, Xcode, Android Studio, Finder, Terminal). +- **Ghostty terminal** is lazy-loaded via dynamic `import("ghostty-web")` with a shared singleton promise. The `loadGhostty` function ensures only one WASM load across all terminal instances. +- **`debug-bar.tsx`** monitors Core Web Vitals (INP, CLS) via `PerformanceObserver` with 5-second rolling windows. Development/debugging tool only. diff --git a/Agent-backend/packages/app/src/components/debug-bar.tsx b/Agent-backend/packages/app/src/components/debug-bar.tsx new file mode 100644 index 000000000..93a4654f8 --- /dev/null +++ b/Agent-backend/packages/app/src/components/debug-bar.tsx @@ -0,0 +1,432 @@ +import { useIsRouting, useLocation } from "@solidjs/router" +import { batch, createEffect, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { Tooltip } from "@opencode-ai/ui/tooltip" + +type Mem = Performance & { + memory?: { + usedJSHeapSize: number + jsHeapSizeLimit: number + } +} + +type Evt = PerformanceEntry & { + interactionId?: number + processingStart?: number +} + +type Shift = PerformanceEntry & { + hadRecentInput: boolean + value: number +} + +type Obs = PerformanceObserverInit & { + durationThreshold?: number +} + +const span = 5000 + +const ms = (n?: number, d = 0) => { + if (n === undefined || Number.isNaN(n)) return "n/a" + return `${n.toFixed(d)}ms` +} + +const time = (n?: number) => { + if (n === undefined || Number.isNaN(n)) return "n/a" + return `${Math.round(n)}` +} + +const mb = (n?: number) => { + if (n === undefined || Number.isNaN(n)) return "n/a" + const v = n / 1024 / 1024 + return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB` +} + +const bad = (n: number | undefined, limit: number, low = false) => { + if (n === undefined || Number.isNaN(n)) return false + return low ? n < limit : n > limit +} + +const session = (path: string) => path.includes("/session") + +function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string }) { + return ( + +
+
{props.label}
+
+ {props.value} +
+
+
+ ) +} + +export function DebugBar() { + const location = useLocation() + const routing = useIsRouting() + const [state, setState] = createStore({ + cls: undefined as number | undefined, + delay: undefined as number | undefined, + fps: undefined as number | undefined, + gap: undefined as number | undefined, + heap: { + limit: undefined as number | undefined, + used: undefined as number | undefined, + }, + inp: undefined as number | undefined, + jank: undefined as number | undefined, + long: { + block: undefined as number | undefined, + count: undefined as number | undefined, + max: undefined as number | undefined, + }, + nav: { + dur: undefined as number | undefined, + pending: false, + }, + }) + + const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) + const heapv = () => { + const value = heap() + if (value === undefined) return "n/a" + return `${Math.round(value * 100)}%` + } + const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`) + const navv = () => (state.nav.pending ? "..." : time(state.nav.dur)) + + let prev = "" + let start = 0 + let init = false + let one = 0 + let two = 0 + + createEffect(() => { + const busy = routing() + const next = `${location.pathname}${location.search}` + + if (!init) { + init = true + prev = next + return + } + + if (busy) { + if (one !== 0) cancelAnimationFrame(one) + if (two !== 0) cancelAnimationFrame(two) + one = 0 + two = 0 + if (start !== 0) return + start = performance.now() + if (session(prev)) setState("nav", { dur: undefined, pending: true }) + return + } + + if (start === 0) { + prev = next + return + } + + const at = start + const from = prev + start = 0 + prev = next + + if (!(session(from) || session(next))) return + + if (one !== 0) cancelAnimationFrame(one) + if (two !== 0) cancelAnimationFrame(two) + one = requestAnimationFrame(() => { + one = 0 + two = requestAnimationFrame(() => { + two = 0 + setState("nav", { dur: performance.now() - at, pending: false }) + }) + }) + }) + + onMount(() => { + const obs: PerformanceObserver[] = [] + const fps: Array<{ at: number; dur: number }> = [] + const long: Array<{ at: number; dur: number }> = [] + const seen = new Map() + let hasLong = false + let poll: number | undefined + let raf = 0 + let last = 0 + let snap = 0 + + const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => { + while (list[0] && at - list[0].at > span) list.shift() + } + + const syncFrame = (at: number) => { + trim(fps, span, at) + const total = fps.reduce((sum, entry) => sum + entry.dur, 0) + const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0) + const jank = fps.filter((entry) => entry.dur > 32).length + batch(() => { + setState("fps", total > 0 ? (fps.length * 1000) / total : undefined) + setState("gap", gap > 0 ? gap : undefined) + setState("jank", jank) + }) + } + + const syncLong = (at = performance.now()) => { + if (!hasLong) return + trim(long, span, at) + const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0) + const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0) + setState("long", { block, count: long.length, max }) + } + + const syncInp = (at = performance.now()) => { + for (const [key, entry] of seen) { + if (at - entry.at > span) seen.delete(key) + } + let delay = 0 + let inp = 0 + for (const entry of seen.values()) { + delay = Math.max(delay, entry.delay) + inp = Math.max(inp, entry.dur) + } + batch(() => { + setState("delay", delay > 0 ? delay : undefined) + setState("inp", inp > 0 ? inp : undefined) + }) + } + + const syncHeap = () => { + const mem = (performance as Mem).memory + if (!mem) return + setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize }) + } + + const reset = () => { + fps.length = 0 + long.length = 0 + seen.clear() + last = 0 + snap = 0 + batch(() => { + setState("fps", undefined) + setState("gap", undefined) + setState("jank", undefined) + setState("delay", undefined) + setState("inp", undefined) + if (hasLong) setState("long", { block: 0, count: 0, max: 0 }) + }) + } + + const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => { + if (typeof PerformanceObserver === "undefined") return false + if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false + const ob = new PerformanceObserver((list) => fn(list.getEntries())) + try { + ob.observe(init) + obs.push(ob) + return true + } catch { + ob.disconnect() + return false + } + } + + if ( + watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => { + const add = entries.reduce((sum, entry) => { + const item = entry as Shift + if (item.hadRecentInput) return sum + return sum + item.value + }, 0) + if (add === 0) return + setState("cls", (value) => (value ?? 0) + add) + }) + ) { + setState("cls", 0) + } + + if ( + watch("longtask", { buffered: true, type: "longtask" }, (entries) => { + const at = performance.now() + long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration }))) + syncLong(at) + }) + ) { + hasLong = true + setState("long", { block: 0, count: 0, max: 0 }) + } + + watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => { + for (const raw of entries) { + const entry = raw as Evt + if (entry.duration < 16) continue + const key = + entry.interactionId && entry.interactionId > 0 + ? entry.interactionId + : `${entry.name}:${Math.round(entry.startTime)}` + const prev = seen.get(key) + const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime) + seen.set(key, { + at: entry.startTime, + delay: Math.max(prev?.delay ?? 0, delay), + dur: Math.max(prev?.dur ?? 0, entry.duration), + }) + if (seen.size <= 200) continue + const first = seen.keys().next().value + if (first !== undefined) seen.delete(first) + } + syncInp() + }) + + const loop = (at: number) => { + if (document.visibilityState !== "visible") { + raf = 0 + return + } + + if (last === 0) { + last = at + raf = requestAnimationFrame(loop) + return + } + + fps.push({ at, dur: at - last }) + last = at + + if (at - snap >= 250) { + snap = at + syncFrame(at) + } + + raf = requestAnimationFrame(loop) + } + + const stop = () => { + if (raf !== 0) cancelAnimationFrame(raf) + raf = 0 + if (poll === undefined) return + clearInterval(poll) + poll = undefined + } + + const start = () => { + if (document.visibilityState !== "visible") return + if (poll === undefined) { + poll = window.setInterval(() => { + syncLong() + syncInp() + syncHeap() + }, 1000) + } + if (raf !== 0) return + raf = requestAnimationFrame(loop) + } + + const vis = () => { + if (document.visibilityState !== "visible") { + stop() + return + } + reset() + start() + } + + syncHeap() + start() + document.addEventListener("visibilitychange", vis) + + onCleanup(() => { + if (one !== 0) cancelAnimationFrame(one) + if (two !== 0) cancelAnimationFrame(two) + stop() + document.removeEventListener("visibilitychange", vis) + for (const ob of obs) ob.disconnect() + }) + }) + + return ( + + ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-connect-provider.tsx b/Agent-backend/packages/app/src/components/dialog-connect-provider.tsx new file mode 100644 index 000000000..b042205cf --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-connect-provider.tsx @@ -0,0 +1,500 @@ +import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { List, type ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Spinner } from "@opencode-ai/ui/spinner" +import { TextField } from "@opencode-ai/ui/text-field" +import { showToast } from "@opencode-ai/ui/toast" +import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { Link } from "@/components/link" +import { useLanguage } from "@/context/language" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { usePlatform } from "@/context/platform" +import { DialogSelectModel } from "./dialog-select-model" +import { DialogSelectProvider } from "./dialog-select-provider" + +export function DialogConnectProvider(props: { provider: string }) { + const dialog = useDialog() + const globalSync = useGlobalSync() + const globalSDK = useGlobalSDK() + const platform = usePlatform() + const language = useLanguage() + + const alive = { value: true } + const timer = { current: undefined as ReturnType | undefined } + + onCleanup(() => { + alive.value = false + if (timer.current === undefined) return + clearTimeout(timer.current) + timer.current = undefined + }) + + const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) + const methods = createMemo( + () => + globalSync.data.provider_auth[props.provider] ?? [ + { + type: "api", + label: language.t("provider.connect.method.apiKey"), + }, + ], + ) + const [store, setStore] = createStore({ + methodIndex: undefined as undefined | number, + authorization: undefined as undefined | ProviderAuthAuthorization, + state: "pending" as undefined | "pending" | "complete" | "error", + error: undefined as string | undefined, + }) + + type Action = + | { type: "method.select"; index: number } + | { type: "method.reset" } + | { type: "auth.pending" } + | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.error"; error: string } + + function dispatch(action: Action) { + setStore( + produce((draft) => { + if (action.type === "method.select") { + draft.methodIndex = action.index + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "method.reset") { + draft.methodIndex = undefined + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "auth.pending") { + draft.state = "pending" + draft.error = undefined + return + } + if (action.type === "auth.complete") { + draft.state = "complete" + draft.authorization = action.authorization + draft.error = undefined + return + } + draft.state = "error" + draft.error = action.error + }), + ) + } + + const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) + + const methodLabel = (value?: { type?: string; label?: string }) => { + if (!value) return "" + if (value.type === "api") return language.t("provider.connect.method.apiKey") + return value.label ?? "" + } + + function formatError(value: unknown, fallback: string): string { + if (value && typeof value === "object" && "data" in value) { + const data = (value as { data?: { message?: unknown } }).data + if (typeof data?.message === "string" && data.message) return data.message + } + if (value && typeof value === "object" && "error" in value) { + const nested = formatError((value as { error?: unknown }).error, "") + if (nested) return nested + } + if (value && typeof value === "object" && "message" in value) { + const message = (value as { message?: unknown }).message + if (typeof message === "string" && message) return message + } + if (value instanceof Error && value.message) return value.message + if (typeof value === "string" && value) return value + return fallback + } + + async function selectMethod(index: number) { + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined + } + + const method = methods()[index] + dispatch({ type: "method.select", index }) + + if (method.type === "oauth") { + dispatch({ type: "auth.pending" }) + const start = Date.now() + await globalSDK.client.provider.oauth + .authorize( + { + providerID: props.provider, + method: index, + }, + { throwOnError: true }, + ) + .then((x) => { + if (!alive.value) return + const elapsed = Date.now() - start + const delay = 1000 - elapsed + + if (delay > 0) { + if (timer.current !== undefined) clearTimeout(timer.current) + timer.current = setTimeout(() => { + timer.current = undefined + if (!alive.value) return + dispatch({ type: "auth.complete", authorization: x.data! }) + }, delay) + return + } + dispatch({ type: "auth.complete", authorization: x.data! }) + }) + .catch((e) => { + if (!alive.value) return + dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) + }) + } + } + + let listRef: ListRef | undefined + function handleKey(e: KeyboardEvent) { + if (e.key === "Enter" && e.target instanceof HTMLInputElement) { + return + } + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + if (methods().length === 1) { + selectMethod(0) + } + }) + + async function complete() { + await globalSDK.client.global.dispose() + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), + description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), + }) + } + + function goBack() { + if (methods().length === 1) { + dialog.show(() => ) + return + } + if (store.authorization) { + dispatch({ type: "method.reset" }) + return + } + if (store.methodIndex !== undefined) { + dispatch({ type: "method.reset" }) + return + } + dialog.show(() => ) + } + + function MethodSelection() { + return ( + <> +
+ {language.t("provider.connect.selectMethod", { provider: provider().name })} +
+
+ { + listRef = ref + }} + items={methods} + key={(m) => m?.label} + onSelect={async (selected, index) => { + if (!selected) return + selectMethod(index) + }} + > + {(i) => ( +
+
+ + {methodLabel(i)} +
+ )} + +
+ + ) + } + + function ApiAuthView() { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", language.t("provider.connect.apiKey.required")) + return + } + + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( +
+ + +
+
{language.t("provider.connect.opencodeZen.line1")}
+
{language.t("provider.connect.opencodeZen.line2")}
+
+ {language.t("provider.connect.opencodeZen.visit.prefix")} + + {language.t("provider.connect.opencodeZen.visit.link")} + + {language.t("provider.connect.opencodeZen.visit.suffix")} +
+
+
+ +
+ {language.t("provider.connect.apiKey.description", { provider: provider().name })} +
+
+
+
+ setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + + +
+ ) + } + + function OAuthCodeView() { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", language.t("provider.connect.oauth.code.required")) + return + } + + setFormStore("error", undefined) + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider, + method: store.methodIndex, + code, + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + if (result.ok) { + await complete() + return + } + setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) + } + + return ( +
+
+ {language.t("provider.connect.oauth.code.visit.prefix")} + {language.t("provider.connect.oauth.code.visit.link")} + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} +
+
+ setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + + +
+ ) + } + + function OAuthAutoView() { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions.split(":")[1]?.trim() + } + return instructions + }) + + onMount(() => { + void (async () => { + if (store.authorization?.url) { + platform.openLink(store.authorization.url) + } + + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider, + method: store.methodIndex, + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + + if (!alive.value) return + + if (!result.ok) { + const message = formatError(result.error, language.t("common.requestFailed")) + dispatch({ type: "auth.error", error: message }) + return + } + + await complete() + })() + }) + + return ( +
+
+ {language.t("provider.connect.oauth.auto.visit.prefix")} + {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} +
+ +
+ + {language.t("provider.connect.status.waiting")} +
+
+ ) + } + + return ( + + } + > +
+
+ +
+ + + {language.t("provider.connect.title.anthropicProMax")} + + {language.t("provider.connect.title", { provider: provider().name })} + +
+
+
+
+ + + + + +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
+ +
+
+ + {language.t("provider.connect.status.failed", { error: store.error ?? "" })} +
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-custom-provider.tsx b/Agent-backend/packages/app/src/components/dialog-custom-provider.tsx new file mode 100644 index 000000000..017b85a2c --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-custom-provider.tsx @@ -0,0 +1,432 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { TextField } from "@opencode-ai/ui/text-field" +import { showToast } from "@opencode-ai/ui/toast" +import { For } from "solid-js" +import { createStore } from "solid-js/store" +import { Link } from "@/components/link" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { DialogSelectProvider } from "./dialog-select-provider" + +const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ +const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" + +type Translator = ReturnType["t"] + +type ModelRow = { + id: string + name: string +} + +type HeaderRow = { + key: string + value: string +} + +type FormState = { + providerID: string + name: string + baseURL: string + apiKey: string + models: ModelRow[] + headers: HeaderRow[] + saving: boolean +} + +type FormErrors = { + providerID: string | undefined + name: string | undefined + baseURL: string | undefined + models: Array<{ id?: string; name?: string }> + headers: Array<{ key?: string; value?: string }> +} + +type ValidateArgs = { + form: FormState + t: Translator + disabledProviders: string[] + existingProviderIDs: Set +} + +function validateCustomProvider(input: ValidateArgs) { + const providerID = input.form.providerID.trim() + const name = input.form.name.trim() + const baseURL = input.form.baseURL.trim() + const apiKey = input.form.apiKey.trim() + + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const key = apiKey && !env ? apiKey : undefined + + const idError = !providerID + ? input.t("provider.custom.error.providerID.required") + : !PROVIDER_ID.test(providerID) + ? input.t("provider.custom.error.providerID.format") + : undefined + + const nameError = !name ? input.t("provider.custom.error.name.required") : undefined + const urlError = !baseURL + ? input.t("provider.custom.error.baseURL.required") + : !/^https?:\/\//.test(baseURL) + ? input.t("provider.custom.error.baseURL.format") + : undefined + + const disabled = input.disabledProviders.includes(providerID) + const existsError = idError + ? undefined + : input.existingProviderIDs.has(providerID) && !disabled + ? input.t("provider.custom.error.providerID.exists") + : undefined + + const seenModels = new Set() + const modelErrors = input.form.models.map((m) => { + const id = m.id.trim() + const modelIdError = !id + ? input.t("provider.custom.error.required") + : seenModels.has(id) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenModels.add(id) + return undefined + })() + const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined + return { id: modelIdError, name: modelNameError } + }) + const modelsValid = modelErrors.every((m) => !m.id && !m.name) + const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) + + const seenHeaders = new Set() + const headerErrors = input.form.headers.map((h) => { + const key = h.key.trim() + const value = h.value.trim() + + if (!key && !value) return {} + const keyError = !key + ? input.t("provider.custom.error.required") + : seenHeaders.has(key.toLowerCase()) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenHeaders.add(key.toLowerCase()) + return undefined + })() + const valueError = !value ? input.t("provider.custom.error.required") : undefined + return { key: keyError, value: valueError } + }) + const headersValid = headerErrors.every((h) => !h.key && !h.value) + const headers = Object.fromEntries( + input.form.headers + .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) + .filter((h) => !!h.key && !!h.value) + .map((h) => [h.key, h.value]), + ) + + const errors: FormErrors = { + providerID: idError ?? existsError, + name: nameError, + baseURL: urlError, + models: modelErrors, + headers: headerErrors, + } + + const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid + if (!ok) return { errors } + + const options = { + baseURL, + ...(Object.keys(headers).length ? { headers } : {}), + } + + return { + errors, + result: { + providerID, + name, + key, + config: { + npm: OPENAI_COMPATIBLE, + name, + ...(env ? { env: [env] } : {}), + options, + models, + }, + }, + } +} + +type Props = { + back?: "providers" | "close" +} + +export function DialogCustomProvider(props: Props) { + const dialog = useDialog() + const globalSync = useGlobalSync() + const globalSDK = useGlobalSDK() + const language = useLanguage() + + const [form, setForm] = createStore({ + providerID: "", + name: "", + baseURL: "", + apiKey: "", + models: [{ id: "", name: "" }], + headers: [{ key: "", value: "" }], + saving: false, + }) + + const [errors, setErrors] = createStore({ + providerID: undefined, + name: undefined, + baseURL: undefined, + models: [{}], + headers: [{}], + }) + + const goBack = () => { + if (props.back === "close") { + dialog.close() + return + } + dialog.show(() => ) + } + + const addModel = () => { + setForm("models", (v) => [...v, { id: "", name: "" }]) + setErrors("models", (v) => [...v, {}]) + } + + const removeModel = (index: number) => { + if (form.models.length <= 1) return + setForm("models", (v) => v.filter((_, i) => i !== index)) + setErrors("models", (v) => v.filter((_, i) => i !== index)) + } + + const addHeader = () => { + setForm("headers", (v) => [...v, { key: "", value: "" }]) + setErrors("headers", (v) => [...v, {}]) + } + + const removeHeader = (index: number) => { + if (form.headers.length <= 1) return + setForm("headers", (v) => v.filter((_, i) => i !== index)) + setErrors("headers", (v) => v.filter((_, i) => i !== index)) + } + + const validate = () => { + const output = validateCustomProvider({ + form, + t: language.t, + disabledProviders: globalSync.data.config.disabled_providers ?? [], + existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), + }) + setErrors(output.errors) + return output.result + } + + const save = async (e: SubmitEvent) => { + e.preventDefault() + if (form.saving) return + + const result = validate() + if (!result) return + + setForm("saving", true) + + const disabledProviders = globalSync.data.config.disabled_providers ?? [] + const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) + + const auth = result.key + ? globalSDK.client.auth.set({ + providerID: result.providerID, + auth: { + type: "api", + key: result.key, + }, + }) + : Promise.resolve() + + auth + .then(() => + globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }), + ) + .then(() => { + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: result.name }), + description: language.t("provider.connect.toast.connected.description", { provider: result.name }), + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => { + setForm("saving", false) + }) + } + + return ( + + } + transition + > +
+
+ +
{language.t("provider.custom.title")}
+
+ +
+

+ {language.t("provider.custom.description.prefix")} + + {language.t("provider.custom.description.link")} + + {language.t("provider.custom.description.suffix")} +

+ +
+ setForm("providerID", v)} + validationState={errors.providerID ? "invalid" : undefined} + error={errors.providerID} + /> + setForm("name", v)} + validationState={errors.name ? "invalid" : undefined} + error={errors.name} + /> + setForm("baseURL", v)} + validationState={errors.baseURL ? "invalid" : undefined} + error={errors.baseURL} + /> + setForm("apiKey", v)} + /> +
+ +
+ + + {(m, i) => ( +
+
+ setForm("models", i(), "id", v)} + validationState={errors.models[i()]?.id ? "invalid" : undefined} + error={errors.models[i()]?.id} + /> +
+
+ setForm("models", i(), "name", v)} + validationState={errors.models[i()]?.name ? "invalid" : undefined} + error={errors.models[i()]?.name} + /> +
+ removeModel(i())} + disabled={form.models.length <= 1} + aria-label={language.t("provider.custom.models.remove")} + /> +
+ )} +
+ +
+ +
+ + + {(h, i) => ( +
+
+ setForm("headers", i(), "key", v)} + validationState={errors.headers[i()]?.key ? "invalid" : undefined} + error={errors.headers[i()]?.key} + /> +
+
+ setForm("headers", i(), "value", v)} + validationState={errors.headers[i()]?.value ? "invalid" : undefined} + error={errors.headers[i()]?.value} + /> +
+ removeHeader(i())} + disabled={form.headers.length <= 1} + aria-label={language.t("provider.custom.headers.remove")} + /> +
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-edit-project.tsx b/Agent-backend/packages/app/src/components/dialog-edit-project.tsx new file mode 100644 index 000000000..ec0793c54 --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-edit-project.tsx @@ -0,0 +1,256 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { Icon } from "@opencode-ai/ui/icon" +import { createMemo, For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { type LocalProject, getAvatarColors } from "@/context/layout" +import { getFilename } from "@opencode-ai/util/path" +import { Avatar } from "@opencode-ai/ui/avatar" +import { useLanguage } from "@/context/language" + +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const + +export function DialogEditProject(props: { project: LocalProject }) { + const dialog = useDialog() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const language = useLanguage() + + const folderName = createMemo(() => getFilename(props.project.worktree)) + const defaultName = createMemo(() => props.project.name || folderName()) + + const [store, setStore] = createStore({ + name: defaultName(), + color: props.project.icon?.color || "pink", + iconUrl: props.project.icon?.override || "", + startup: props.project.commands?.start ?? "", + saving: false, + dragOver: false, + iconHover: false, + }) + + let iconInput: HTMLInputElement | undefined + + function handleFileSelect(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (e) => { + setStore("iconUrl", e.target?.result as string) + setStore("iconHover", false) + } + reader.readAsDataURL(file) + } + + function handleDrop(e: DragEvent) { + e.preventDefault() + setStore("dragOver", false) + const file = e.dataTransfer?.files[0] + if (file) handleFileSelect(file) + } + + function handleDragOver(e: DragEvent) { + e.preventDefault() + setStore("dragOver", true) + } + + function handleDragLeave() { + setStore("dragOver", false) + } + + function handleInputChange(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file) handleFileSelect(file) + } + + function clearIcon() { + setStore("iconUrl", "") + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + await Promise.resolve() + .then(async () => { + setStore("saving", true) + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() + + if (props.project.id && props.project.id !== "global") { + await globalSDK.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color, override: store.iconUrl }, + commands: { start }, + }) + globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) + dialog.close() + return + } + + globalSync.project.meta(props.project.worktree, { + name, + icon: { color: store.color, override: store.iconUrl || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }) + .finally(() => { + setStore("saving", false) + }) + } + + return ( + +
+
+ setStore("name", v)} + /> + +
+ +
+
setStore("iconHover", true)} + onMouseLeave={() => setStore("iconHover", false)} + > +
{ + if (store.iconUrl && store.iconHover) { + clearIcon() + } else { + iconInput?.click() + } + }} + > + + +
+ } + > + {language.t("dialog.project.edit.icon.alt")} + +
+
+ +
+
+ +
+
+ { + iconInput = el + }} + type="file" + accept="image/*" + class="hidden" + onChange={handleInputChange} + /> +
+ {language.t("dialog.project.edit.icon.hint")} + {language.t("dialog.project.edit.icon.recommended")} +
+
+
+ + +
+ +
+ + {(color) => ( + + )} + +
+
+
+ + setStore("startup", v)} + spellcheck={false} + class="max-h-14 w-full overflow-y-auto font-mono text-xs" + /> +
+ +
+ + +
+ + + ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-fork.tsx b/Agent-backend/packages/app/src/components/dialog-fork.tsx new file mode 100644 index 000000000..8810955cc --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-fork.tsx @@ -0,0 +1,109 @@ +import { Component, createMemo } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { usePrompt } from "@/context/prompt" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { showToast } from "@opencode-ai/ui/toast" +import { extractPromptFromParts } from "@/utils/prompt" +import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" +import { useLanguage } from "@/context/language" + +interface ForkableMessage { + id: string + text: string + time: string +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString(undefined, { timeStyle: "short" }) +} + +export const DialogFork: Component = () => { + const params = useParams() + const navigate = useNavigate() + const sync = useSync() + const sdk = useSDK() + const prompt = usePrompt() + const dialog = useDialog() + const language = useLanguage() + + const messages = createMemo((): ForkableMessage[] => { + const sessionID = params.id + if (!sessionID) return [] + + const msgs = sync.data.message[sessionID] ?? [] + const result: ForkableMessage[] = [] + + for (const message of msgs) { + if (message.role !== "user") continue + + const parts = sync.data.part[message.id] ?? [] + const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) + if (!textPart) continue + + result.push({ + id: message.id, + text: textPart.text.replace(/\n/g, " ").slice(0, 200), + time: formatTime(new Date(message.time.created)), + }) + } + + return result.reverse() + }) + + const handleSelect = (item: ForkableMessage | undefined) => { + if (!item) return + + const sessionID = params.id + if (!sessionID) return + + const parts = sync.data.part[item.id] ?? [] + const restored = extractPromptFromParts(parts, { + directory: sdk.directory, + attachmentName: language.t("common.attachment"), + }) + + sdk.client.session + .fork({ sessionID, messageID: item.id }) + .then((forked) => { + if (!forked.data) { + showToast({ title: language.t("common.requestFailed") }) + return + } + dialog.close() + navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) + requestAnimationFrame(() => { + prompt.set(restored) + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } + + return ( + + x.id} + items={messages} + filterKeys={["text"]} + onSelect={handleSelect} + > + {(item) => ( +
+ {item.text} + {item.time} +
+ )} +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-manage-models.tsx b/Agent-backend/packages/app/src/components/dialog-manage-models.tsx new file mode 100644 index 000000000..ace79e38a --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-manage-models.tsx @@ -0,0 +1,101 @@ +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Button } from "@opencode-ai/ui/button" +import type { Component } from "solid-js" +import { useLocal } from "@/context/local" +import { popularProviders } from "@/hooks/use-providers" +import { useLanguage } from "@/context/language" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectProvider } from "./dialog-select-provider" + +export const DialogManageModels: Component = () => { + const local = useLocal() + const language = useLanguage() + const dialog = useDialog() + + const handleConnectProvider = () => { + dialog.show(() => ) + } + const providerRank = (id: string) => popularProviders.indexOf(id) + const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) + const providerVisible = (providerID: string) => + providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id })) + const setProviderVisibility = (providerID: string, checked: boolean) => { + providerList(providerID).forEach((x) => { + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked) + }) + } + + return ( + + {language.t("command.provider.connect")} + + } + > + `${x?.provider?.id}:${x?.id}`} + items={local.model.list()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.id} + groupHeader={(group) => { + const provider = group.items[0].provider + return ( + <> + {provider.name} + + setProviderVisibility(provider.id, checked)} + hideLabel + > + {provider.name} + + + + ) + }} + sortGroupsBy={(a, b) => { + const aRank = providerRank(a.items[0].provider.id) + const bRank = providerRank(b.items[0].provider.id) + const aPopular = aRank >= 0 + const bPopular = bRank >= 0 + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + return aRank - bRank + }} + onSelect={(x) => { + if (!x) return + const key = { modelID: x.id, providerID: x.provider.id } + local.model.setVisibility(key, !local.model.visible(key)) + }} + > + {(i) => ( +
+ {i.name} +
e.stopPropagation()}> + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
+
+ )} +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-release-notes.tsx b/Agent-backend/packages/app/src/components/dialog-release-notes.tsx new file mode 100644 index 000000000..d0a35b71b --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-release-notes.tsx @@ -0,0 +1,144 @@ +import { createSignal } from "solid-js" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" + +export type Highlight = { + title: string + description: string + media?: { + type: "image" | "video" + src: string + alt?: string + } +} + +export function DialogReleaseNotes(props: { highlights: Highlight[] }) { + const dialog = useDialog() + const language = useLanguage() + const settings = useSettings() + const [index, setIndex] = createSignal(0) + + const total = () => props.highlights.length + const last = () => Math.max(0, total() - 1) + const feature = () => props.highlights[index()] ?? props.highlights[last()] + const isFirst = () => index() === 0 + const isLast = () => index() >= last() + const paged = () => total() > 1 + + function handleNext() { + if (isLast()) return + setIndex(index() + 1) + } + + function handleClose() { + dialog.close() + } + + function handleDisable() { + settings.general.setReleaseNotes(false) + handleClose() + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault() + handleClose() + return + } + + if (!paged()) return + if (e.key === "ArrowLeft" && !isFirst()) { + e.preventDefault() + setIndex(index() - 1) + } + if (e.key === "ArrowRight" && !isLast()) { + e.preventDefault() + setIndex(index() + 1) + } + } + + return ( + +
+ {/* Left side - Text content */} +
+ {/* Top section - feature content (fixed position from top) */} +
+
+

{feature()?.title ?? ""}

+
+

{feature()?.description ?? ""}

+
+ + {/* Spacer to push buttons to bottom */} +
+ + {/* Bottom section - buttons and indicators (fixed position) */} +
+
+ {isLast() ? ( + + ) : ( + + )} + + +
+ + {paged() && ( +
+ {props.highlights.map((_, i) => ( + + ))} +
+ )} +
+
+ + {/* Right side - Media content (edge to edge) */} + {feature()?.media && ( +
+ {feature()!.media!.type === "image" ? ( + {feature()!.media!.alt + ) : ( +
+ )} +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-select-directory.tsx b/Agent-backend/packages/app/src/components/dialog-select-directory.tsx new file mode 100644 index 000000000..91e23f8ff --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-select-directory.tsx @@ -0,0 +1,392 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { List } from "@opencode-ai/ui/list" +import type { ListRef } from "@opencode-ai/ui/list" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import fuzzysort from "fuzzysort" +import { createMemo, createResource, createSignal } from "solid-js" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { useLayout } from "@/context/layout" +import { useLanguage } from "@/context/language" + +interface DialogSelectDirectoryProps { + title?: string + multiple?: boolean + onSelect: (result: string | string[] | null) => void +} + +type Row = { + absolute: string + search: string + group: "recent" | "folders" +} + +function cleanInput(value: string) { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() +} + +function normalizePath(input: string) { + const v = input.replaceAll("\\", "/") + if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") + return v.replace(/\/+/g, "/") +} + +function normalizeDriveRoot(input: string) { + const v = normalizePath(input) + if (/^[A-Za-z]:$/.test(v)) return v + "/" + return v +} + +function trimTrailing(input: string) { + const v = normalizeDriveRoot(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + return v.replace(/\/+$/, "") +} + +function joinPath(base: string | undefined, rel: string) { + const b = trimTrailing(base ?? "") + const r = trimTrailing(rel).replace(/^\/+/, "") + if (!b) return r + if (!r) return b + if (b.endsWith("/")) return b + r + return b + "/" + r +} + +function rootOf(input: string) { + const v = normalizeDriveRoot(input) + if (v.startsWith("//")) return "//" + if (v.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) + return "" +} + +function parentOf(input: string) { + const v = trimTrailing(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + + const i = v.lastIndexOf("/") + if (i <= 0) return "/" + if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) + return v.slice(0, i) +} + +function modeOf(input: string) { + const raw = normalizeDriveRoot(input.trim()) + if (!raw) return "relative" as const + if (raw.startsWith("~")) return "tilde" as const + if (rootOf(raw)) return "absolute" as const + return "relative" as const +} + +function tildeOf(absolute: string, home: string) { + const full = trimTrailing(absolute) + if (!home) return "" + + const hn = trimTrailing(home) + const lc = full.toLowerCase() + const hc = hn.toLowerCase() + if (lc === hc) return "~" + if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) + return "" +} + +function displayPath(path: string, input: string, home: string) { + const full = trimTrailing(path) + if (modeOf(input) === "absolute") return full + return tildeOf(full, home) || full +} + +function toRow(absolute: string, home: string, group: Row["group"]): Row { + const full = trimTrailing(absolute) + const tilde = tildeOf(full, home) + const withSlash = (value: string) => { + if (!value) return "" + if (value.endsWith("/")) return value + return value + "/" + } + + const search = Array.from( + new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), + ).join("\n") + return { absolute: full, search, group } +} + +function uniqueRows(rows: Row[]) { + const seen = new Set() + return rows.filter((row) => { + if (seen.has(row.absolute)) return false + seen.add(row.absolute) + return true + }) +} + +function useDirectorySearch(args: { + sdk: ReturnType + start: () => string | undefined + home: () => string +}) { + const cache = new Map>>() + let current = 0 + + const scoped = (value: string) => { + const base = args.start() + if (!base) return + + const raw = normalizeDriveRoot(value) + if (!raw) return { directory: trimTrailing(base), path: "" } + + const h = args.home() + if (raw === "~") return { directory: trimTrailing(h || base), path: "" } + if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) } + + const root = rootOf(raw) + if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) } + return { directory: trimTrailing(base), path: raw } + } + + const dirs = async (dir: string) => { + const key = trimTrailing(dir) + const existing = cache.get(key) + if (existing) return existing + + const request = args.sdk.client.file + .list({ directory: key, path: "" }) + .then((x) => x.data ?? []) + .catch(() => []) + .then((nodes) => + nodes + .filter((n) => n.type === "directory") + .map((n) => ({ + name: n.name, + absolute: trimTrailing(normalizeDriveRoot(n.absolute)), + })), + ) + + cache.set(key, request) + return request + } + + const match = async (dir: string, query: string, limit: number) => { + const items = await dirs(dir) + if (!query) return items.slice(0, limit).map((x) => x.absolute) + return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute) + } + + return async (filter: string) => { + const token = ++current + const active = () => token === current + + const value = cleanInput(filter) + const scopedInput = scoped(value) + if (!scopedInput) return [] as string[] + + const raw = normalizeDriveRoot(value) + const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") + const query = normalizeDriveRoot(scopedInput.path) + + const find = () => + args.sdk.client.find + .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) + .then((x) => x.data ?? []) + .catch(() => []) + + if (!isPath) { + const results = await find() + if (!active()) return [] + return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50) + } + + const segments = query.replace(/^\/+/, "").split("/") + const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".") + const tail = segments[segments.length - 1] ?? "" + + const cap = 12 + const branch = 4 + let paths = [scopedInput.directory] + for (const part of head) { + if (!active()) return [] + if (part === "..") { + paths = paths.map(parentOf) + continue + } + + const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat() + if (!active()) return [] + paths = Array.from(new Set(next)).slice(0, cap) + if (paths.length === 0) return [] as string[] + } + + const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() + if (!active()) return [] + const deduped = Array.from(new Set(out)) + const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" + const expand = !raw.endsWith("/") + if (!expand || !tail) { + const items = base ? Array.from(new Set([base, ...deduped])) : deduped + return items.slice(0, 50) + } + + const needle = tail.toLowerCase() + const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle) + const target = exact[0] + if (!target) return deduped.slice(0, 50) + + const children = await match(target, "", 30) + if (!active()) return [] + const items = Array.from(new Set([...deduped, ...children])) + return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) + } +} + +export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { + const sync = useGlobalSync() + const sdk = useGlobalSDK() + const layout = useLayout() + const dialog = useDialog() + const language = useLanguage() + + const [filter, setFilter] = createSignal("") + let list: ListRef | undefined + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + async () => { + return sdk.client.path + .get() + .then((x) => x.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + const start = createMemo( + () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) + + const directories = useDirectorySearch({ + sdk, + home, + start, + }) + + const recentProjects = createMemo(() => { + const projects = layout.projects.list() + const byProject = new Map() + + for (const project of projects) { + let at = 0 + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + for (const directory of dirs) { + const sessions = sync.child(directory, { bootstrap: false })[0].session + for (const session of sessions) { + if (session.time.archived) continue + const updated = session.time.updated ?? session.time.created + if (updated > at) at = updated + } + } + byProject.set(project.worktree, at) + } + + return projects + .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) + .sort((a, b) => b.at - a.at || a.index - b.index) + .slice(0, 5) + .map(({ project }) => { + const row = toRow(project.worktree, home(), "recent") + const name = project.name || getFilename(project.worktree) + return { + ...row, + search: `${row.search}\n${name}`, + } + }) + }) + + const items = async (value: string) => { + const results = await directories(value) + const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) + return uniqueRows([...recentProjects(), ...directoryRows]) + } + + function resolve(absolute: string) { + props.onSelect(props.multiple ? [absolute] : absolute) + dialog.close() + } + + return ( + + x.absolute} + filterKeys={["search"]} + groupBy={(item) => item.group} + sortGroupsBy={(a, b) => { + if (a.category === b.category) return 0 + return a.category === "recent" ? -1 : 1 + }} + groupHeader={(group) => + group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open") + } + ref={(r) => (list = r)} + onFilter={(value) => setFilter(cleanInput(value))} + onKeyEvent={(e, item) => { + if (e.key !== "Tab") return + if (e.shiftKey) return + if (!item) return + + e.preventDefault() + e.stopPropagation() + + const value = displayPath(item.absolute, filter(), home()) + list?.setFilter(value.endsWith("/") ? value : value + "/") + }} + onSelect={(path) => { + if (!path) return + resolve(path.absolute) + }} + > + {(item) => { + const path = displayPath(item.absolute, filter(), home()) + if (path === "~") { + return ( +
+
+ +
+ ~ + / +
+
+
+ ) + } + return ( +
+
+ +
+ + {getDirectory(path)} + + {getFilename(path)} + / +
+
+
+ ) + }} +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-select-file.tsx b/Agent-backend/packages/app/src/components/dialog-select-file.tsx new file mode 100644 index 000000000..b530aff53 --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-select-file.tsx @@ -0,0 +1,462 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { Keybind } from "@opencode-ai/ui/keybind" +import { List } from "@opencode-ai/ui/list" +import { base64Encode } from "@opencode-ai/util/encode" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { useNavigate, useParams } from "@solidjs/router" +import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" +import { formatKeybind, useCommand, type CommandOption } from "@/context/command" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { useLayout } from "@/context/layout" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" +import { getRelativeTime } from "@/utils/time" + +type EntryType = "command" | "file" | "session" + +type Entry = { + id: string + type: EntryType + title: string + description?: string + keybind?: string + category: string + option?: CommandOption + path?: string + directory?: string + sessionID?: string + archived?: number + updated?: number +} + +type DialogSelectFileMode = "all" | "files" + +const ENTRY_LIMIT = 5 +const COMMON_COMMAND_IDS = [ + "session.new", + "workspace.new", + "session.previous", + "session.next", + "terminal.toggle", + "review.toggle", +] as const + +const uniqueEntries = (items: Entry[]) => { + const seen = new Set() + const out: Entry[] = [] + for (const item of items) { + if (seen.has(item.id)) continue + seen.add(item.id) + out.push(item) + } + return out +} + +const createCommandEntry = (option: CommandOption, category: string): Entry => ({ + id: "command:" + option.id, + type: "command", + title: option.title, + description: option.description, + keybind: option.keybind, + category, + option, +}) + +const createFileEntry = (path: string, category: string): Entry => ({ + id: "file:" + path, + type: "file", + title: path, + category, + path, +}) + +const createSessionEntry = ( + input: { + directory: string + id: string + title: string + description: string + archived?: number + updated?: number + }, + category: string, +): Entry => ({ + id: `session:${input.directory}:${input.id}`, + type: "session", + title: input.title, + description: input.description, + category, + directory: input.directory, + sessionID: input.id, + archived: input.archived, + updated: input.updated, +}) + +function createCommandEntries(props: { + filesOnly: () => boolean + command: ReturnType + language: ReturnType +}) { + const allowed = createMemo(() => { + if (props.filesOnly()) return [] + return props.command.options.filter( + (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", + ) + }) + + const list = createMemo(() => { + const category = props.language.t("palette.group.commands") + return allowed().map((option) => createCommandEntry(option, category)) + }) + + const picks = createMemo(() => { + const all = allowed() + const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) + const picked = all.filter((option) => order.has(option.id)) + const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) + const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base + const category = props.language.t("palette.group.commands") + return sorted.map((option) => createCommandEntry(option, category)) + }) + + return { allowed, list, picks } +} + +function createFileEntries(props: { + file: ReturnType + tabs: () => ReturnType["tabs"]> + language: ReturnType +}) { + const recent = createMemo(() => { + const all = props.tabs().all() + const active = props.tabs().active() + const order = active ? [active, ...all.filter((item) => item !== active)] : all + const seen = new Set() + const category = props.language.t("palette.group.files") + const items: Entry[] = [] + + for (const item of order) { + const path = props.file.pathFromTab(item) + if (!path) continue + if (seen.has(path)) continue + seen.add(path) + items.push(createFileEntry(path, category)) + } + + return items.slice(0, ENTRY_LIMIT) + }) + + const root = createMemo(() => { + const category = props.language.t("palette.group.files") + const nodes = props.file.tree.children("") + const paths = nodes + .filter((node) => node.type === "file") + .map((node) => node.path) + .sort((a, b) => a.localeCompare(b)) + return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category)) + }) + + return { recent, root } +} + +function createSessionEntries(props: { + workspaces: () => string[] + label: (directory: string) => string + globalSDK: ReturnType + language: ReturnType +}) { + const state: { + token: number + inflight: Promise | undefined + cached: Entry[] | undefined + } = { + token: 0, + inflight: undefined, + cached: undefined, + } + + const sessions = (text: string) => { + const query = text.trim() + if (!query) { + state.token += 1 + state.inflight = undefined + state.cached = undefined + return [] as Entry[] + } + + if (state.cached) return state.cached + if (state.inflight) return state.inflight + + const current = state.token + const dirs = props.workspaces() + if (dirs.length === 0) return [] as Entry[] + + state.inflight = Promise.all( + dirs.map((directory) => { + const description = props.label(directory) + return props.globalSDK.client.session + .list({ directory, roots: true }) + .then((x) => + (x.data ?? []) + .filter((s) => !!s?.id) + .map((s) => ({ + id: s.id, + title: s.title ?? props.language.t("command.session.new"), + description, + directory, + archived: s.time?.archived, + updated: s.time?.updated, + })), + ) + .catch( + () => + [] as { + id: string + title: string + description: string + directory: string + archived?: number + updated?: number + }[], + ) + }), + ) + .then((results) => { + if (state.token !== current) return [] as Entry[] + const seen = new Set() + const category = props.language.t("command.category.session") + const next = results + .flat() + .filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map((item) => createSessionEntry(item, category)) + state.cached = next + return next + }) + .catch(() => [] as Entry[]) + .finally(() => { + state.inflight = undefined + }) + + return state.inflight + } + + return { sessions } +} + +export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { + const command = useCommand() + const language = useLanguage() + const layout = useLayout() + const file = useFile() + const dialog = useDialog() + const params = useParams() + const navigate = useNavigate() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const filesOnly = () => props.mode === "files" + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const [grouped, setGrouped] = createSignal(false) + const commandEntries = createCommandEntries({ filesOnly, command, language }) + const fileEntries = createFileEntries({ file, tabs, language }) + + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const workspaces = createMemo(() => { + const directory = projectDirectory() + const current = project() + if (!current) return directory ? [directory] : [] + + const dirs = [current.worktree, ...(current.sandboxes ?? [])] + if (directory && !dirs.includes(directory)) return [...dirs, directory] + return dirs + }) + const homedir = createMemo(() => globalSync.data.path.home) + const label = (directory: string) => { + const current = project() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = globalSync.child(directory, { bootstrap: false }) + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + } + + const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language }) + + const items = async (text: string) => { + const query = text.trim() + setGrouped(query.length > 0) + + if (!query && filesOnly()) { + const loaded = file.tree.state("")?.loaded + const pending = loaded ? Promise.resolve() : file.tree.list("") + const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) + + if (loaded || next.length > 0) { + void pending + return next + } + + await pending + return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) + } + + if (!query) return [...commandEntries.picks(), ...fileEntries.recent()] + + if (filesOnly()) { + const files = await file.searchFiles(query) + const category = language.t("palette.group.files") + return files.map((path) => createFileEntry(path, category)) + } + + const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) + const category = language.t("palette.group.files") + const entries = files.map((path) => createFileEntry(path, category)) + return [...commandEntries.list(), ...nextSessions, ...entries] + } + + const handleMove = (item: Entry | undefined) => { + state.cleanup?.() + if (!item) return + if (item.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + + const open = (path: string) => { + const value = file.tab(path) + tabs().open(value) + file.load(path) + if (!view().reviewPanel.opened()) view().reviewPanel.open() + layout.fileTree.setTab("all") + props.onOpenFile?.(path) + tabs().setActive(value) + } + + const handleSelect = (item: Entry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + + if (item.type === "session") { + if (!item.directory || !item.sessionID) return + navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + return + } + + if (!item.path) return + open(item.path) + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return ( + + item.id} + filterKeys={["title", "description", "category"]} + groupBy={grouped() ? (item) => item.category : () => ""} + onMove={handleMove} + onSelect={handleSelect} + > + {(item) => ( + +
+ +
+ + {getDirectory(item.path ?? "")} + + {getFilename(item.path ?? "")} +
+
+
+ } + > + +
+
+ {item.title} + + {item.description} + +
+ + {formatKeybind(item.keybind ?? "")} + +
+
+ +
+
+ +
+ + {item.title} + + + + {item.description} + + +
+
+ + + {getRelativeTime(new Date(item.updated!).toISOString(), language.t)} + + +
+
+ + )} + + + ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-select-mcp.tsx b/Agent-backend/packages/app/src/components/dialog-select-mcp.tsx new file mode 100644 index 000000000..f8913eee4 --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-select-mcp.tsx @@ -0,0 +1,103 @@ +import { Component, createMemo, createSignal, Show } from "solid-js" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" +import { useLanguage } from "@/context/language" + +const statusLabels = { + connected: "mcp.status.connected", + failed: "mcp.status.failed", + needs_auth: "mcp.status.needs_auth", + disabled: "mcp.status.disabled", +} as const + +export const DialogSelectMcp: Component = () => { + const sync = useSync() + const sdk = useSDK() + const language = useLanguage() + const [loading, setLoading] = createSignal(null) + + const items = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + + const toggle = async (name: string) => { + if (loading()) return + setLoading(name) + try { + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + } finally { + setLoading(null) + } + } + + const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) + const totalCount = createMemo(() => items().length) + + return ( + + x?.name ?? ""} + items={items} + filterKeys={["name", "status"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + onSelect={(x) => { + if (x) toggle(x.name) + }} + > + {(i) => { + const mcpStatus = () => sync.data.mcp[i.name] + const status = () => mcpStatus()?.status + const statusLabel = () => { + const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined + if (!key) return + return language.t(key) + } + const error = () => { + const s = mcpStatus() + return s?.status === "failed" ? s.error : undefined + } + const enabled = () => status() === "connected" + return ( +
+
+
+ {i.name} + + {statusLabel()} + + + {language.t("common.loading.ellipsis")} + +
+ + {error()} + +
+
e.stopPropagation()}> + toggle(i.name)} /> +
+
+ ) + }} +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-select-model-unpaid.tsx b/Agent-backend/packages/app/src/components/dialog-select-model-unpaid.tsx new file mode 100644 index 000000000..bcee3f501 --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -0,0 +1,135 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, type ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tag } from "@opencode-ai/ui/tag" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { type Component, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { DialogConnectProvider } from "./dialog-connect-provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { ModelTooltip } from "./model-tooltip" +import { useLanguage } from "@/context/language" + +export const DialogSelectModelUnpaid: Component = () => { + const local = useLocal() + const dialog = useDialog() + const providers = useProviders() + const language = useLanguage() + + let listRef: ListRef | undefined + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + return ( + +
+
{language.t("dialog.model.unpaid.freeModels.title")}
+ (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + itemWrapper={(item, node) => ( + + } + > + {node} + + )} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.close() + }} + > + {(i) => ( +
+ {i.name} + {language.t("model.tag.free")} + + {language.t("model.tag.latest")} + +
+ )} +
+
+
+
+
+
{language.t("dialog.model.unpaid.addMore.title")}
+
+ x?.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + dialog.show(() => ) + }} + > + {(i) => ( +
+ + {i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
+ + {language.t("dialog.provider.tag.recommended")} + + + <> +
+ {language.t("dialog.provider.opencodeGo.tagline")} +
+ {language.t("dialog.provider.tag.recommended")} + +
+ +
{language.t("dialog.provider.anthropic.note")}
+
+
+ )} +
+ +
+
+
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-select-model.tsx b/Agent-backend/packages/app/src/components/dialog-select-model.tsx new file mode 100644 index 000000000..9f7afb8cd --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-select-model.tsx @@ -0,0 +1,215 @@ +import { Popover as Kobalte } from "@kobalte/core/popover" +import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js" +import { createStore } from "solid-js/store" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogManageModels } from "./dialog-manage-models" +import { ModelTooltip } from "./model-tooltip" +import { useLanguage } from "@/context/language" + +const isFree = (provider: string, cost: { input: number } | undefined) => + provider === "opencode" && (!cost || cost.input === 0) + +const ModelList: Component<{ + provider?: string + class?: string + onSelect: () => void + action?: JSX.Element +}> = (props) => { + const local = useLocal() + const language = useLanguage() + + const models = createMemo(() => + local.model + .list() + .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id })) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + ) + + return ( + `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + itemWrapper={(item, node) => ( + } + > + {node} + + )} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + props.onSelect() + }} + > + {(i) => ( +
+ {i.name} + + {language.t("model.tag.free")} + + + {language.t("model.tag.latest")} + +
+ )} +
+ ) +} + +type ModelSelectorTriggerProps = Omit, "as" | "ref"> + +export function ModelSelectorPopover(props: { + provider?: string + children?: JSX.Element + triggerAs?: ValidComponent + triggerProps?: ModelSelectorTriggerProps +}) { + const [store, setStore] = createStore<{ + open: boolean + dismiss: "escape" | "outside" | null + }>({ + open: false, + dismiss: null, + }) + const dialog = useDialog() + + const handleManage = () => { + setStore("open", false) + dialog.show(() => ) + } + + const handleConnectProvider = () => { + setStore("open", false) + dialog.show(() => ) + } + const language = useLanguage() + + return ( + { + if (next) setStore("dismiss", null) + setStore("open", next) + }} + modal={false} + placement="top-start" + gutter={4} + > + + {props.children} + + + { + setStore("dismiss", "escape") + setStore("open", false) + event.preventDefault() + event.stopPropagation() + }} + onPointerDownOutside={() => { + setStore("dismiss", "outside") + setStore("open", false) + }} + onFocusOutside={() => { + setStore("dismiss", "outside") + setStore("open", false) + }} + onCloseAutoFocus={(event) => { + if (store.dismiss === "outside") event.preventDefault() + setStore("dismiss", null) + }} + > + {language.t("dialog.model.select.title")} + setStore("open", false)} + class="p-1" + action={ +
+ + + + + + +
+ } + /> +
+
+
+ ) +} + +export const DialogSelectModel: Component<{ provider?: string }> = (props) => { + const dialog = useDialog() + const language = useLanguage() + + return ( + dialog.show(() => )} + > + {language.t("command.provider.connect")} + + } + > + dialog.close()} /> + + + ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-select-provider.tsx b/Agent-backend/packages/app/src/components/dialog-select-provider.tsx new file mode 100644 index 000000000..e53738399 --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-select-provider.tsx @@ -0,0 +1,86 @@ +import { Component, Show } from "solid-js" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Tag } from "@opencode-ai/ui/tag" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { DialogConnectProvider } from "./dialog-connect-provider" +import { useLanguage } from "@/context/language" +import { DialogCustomProvider } from "./dialog-custom-provider" + +const CUSTOM_ID = "_custom" + +export const DialogSelectProvider: Component = () => { + const dialog = useDialog() + const providers = useProviders() + const language = useLanguage() + + const popularGroup = () => language.t("dialog.provider.group.popular") + const otherGroup = () => language.t("dialog.provider.group.other") + const customLabel = () => language.t("settings.providers.tag.custom") + const note = (id: string) => { + if (id === "anthropic") return language.t("dialog.provider.anthropic.note") + if (id === "openai") return language.t("dialog.provider.openai.note") + if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") + } + + return ( + + x?.id} + items={() => { + language.locale() + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()] + }} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} + sortBy={(a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + const popular = popularGroup() + if (a.category === popular && b.category !== popular) return -1 + if (b.category === popular && a.category !== popular) return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + if (x.id === CUSTOM_ID) { + dialog.show(() => ) + return + } + dialog.show(() => ) + }} + > + {(i) => ( +
+ + {i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
+ + {language.t("settings.providers.tag.custom")} + + + {language.t("dialog.provider.tag.recommended")} + + {(value) =>
{value()}
}
+ + {language.t("dialog.provider.tag.recommended")} + +
+ )} +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-select-server.tsx b/Agent-backend/packages/app/src/components/dialog-select-server.tsx new file mode 100644 index 000000000..4813ecc45 --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-select-server.tsx @@ -0,0 +1,646 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { List } from "@opencode-ai/ui/list" +import { TextField } from "@opencode-ai/ui/text-field" +import { showToast } from "@opencode-ai/ui/toast" +import { useNavigate } from "@solidjs/router" +import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { checkServerHealth, type ServerHealth } from "@/utils/server-health" + +interface ServerFormProps { + value: string + name: string + username: string + password: string + placeholder: string + busy: boolean + error: string + status: boolean | undefined + onChange: (value: string) => void + onNameChange: (value: string) => void + onUsernameChange: (value: string) => void + onPasswordChange: (value: string) => void + onSubmit: () => void + onBack: () => void +} + +function showRequestError(language: ReturnType, err: unknown) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +function useDefaultServer(platform: ReturnType, language: ReturnType) { + const [defaultUrl, defaultUrlActions] = createResource( + async () => { + try { + const url = await platform.getDefaultServerUrl?.() + if (!url) return null + return normalizeServerUrl(url) ?? null + } catch (err) { + showRequestError(language, err) + return null + } + }, + { initialValue: null }, + ) + + const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) + const setDefault = async (url: string | null) => { + try { + await platform.setDefaultServerUrl?.(url) + defaultUrlActions.mutate(url) + } catch (err) { + showRequestError(language, err) + } + } + + return { defaultUrl, canDefault, setDefault } +} + +function useServerPreview(fetcher: typeof fetch) { + const looksComplete = (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) return false + const host = normalized.replace(/^https?:\/\//, "").split("/")[0] + if (!host) return false + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true + return host.includes(".") || host.includes(":") + } + + const previewStatus = async ( + value: string, + username: string, + password: string, + setStatus: (value: boolean | undefined) => void, + ) => { + setStatus(undefined) + if (!looksComplete(value)) return + const normalized = normalizeServerUrl(value) + if (!normalized) return + const http: ServerConnection.HttpBase = { url: normalized } + if (username) http.username = username + if (password) http.password = password + const result = await checkServerHealth(http, fetcher) + setStatus(result.healthy) + } + + return { previewStatus } +} + +function ServerForm(props: ServerFormProps) { + const language = useLanguage() + const keyDown = (event: KeyboardEvent) => { + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + props.onBack() + return + } + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + props.onSubmit() + } + + return ( +
+
+
+ +
+ +
+ + +
+
+
+ ) +} + +export function DialogSelectServer() { + const navigate = useNavigate() + const dialog = useDialog() + const server = useServer() + const platform = usePlatform() + const language = useLanguage() + const fetcher = platform.fetch ?? globalThis.fetch + const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) + const { previewStatus } = useServerPreview(fetcher) + const [store, setStore] = createStore({ + status: {} as Record, + addServer: { + url: "", + name: "", + username: "", + password: "", + adding: false, + error: "", + showForm: false, + status: undefined as boolean | undefined, + }, + editServer: { + id: undefined as string | undefined, + value: "", + name: "", + username: "", + password: "", + error: "", + busy: false, + status: undefined as boolean | undefined, + }, + }) + + const resetAdd = () => { + setStore("addServer", { + url: "", + name: "", + username: "", + password: "", + adding: false, + error: "", + showForm: false, + status: undefined, + }) + } + const resetEdit = () => { + setStore("editServer", { + id: undefined, + value: "", + name: "", + username: "", + password: "", + error: "", + status: undefined, + busy: false, + }) + } + + const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { + const active = server.key + const newConn = server.add(next) + if (!newConn) return + const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active + if (nextActive) server.setActive(nextActive) + server.remove(ServerConnection.key(original)) + } + + const items = createMemo(() => { + const current = server.current + const list = server.list + if (!current) return list + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((x) => x !== current)] + }) + + const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) + + const sortedItems = createMemo(() => { + const list = items() + if (!list.length) return list + const active = current() + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerHealth) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + return list.slice().sort((a, b) => { + if (a === active) return -1 + if (b === active) return 1 + const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) + }) + + async function refreshHealth() { + const results: Record = {} + await Promise.all( + items().map(async (conn) => { + results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) + }), + ) + setStore("status", reconcile(results)) + } + + createEffect(() => { + items() + refreshHealth() + const interval = setInterval(refreshHealth, 10_000) + onCleanup(() => clearInterval(interval)) + }) + + async function select(conn: ServerConnection.Any, persist?: boolean) { + if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return + dialog.close() + if (persist && conn.type === "http") { + server.add(conn) + navigate("/") + return + } + server.setActive(ServerConnection.key(conn)) + navigate("/") + } + + const handleAddChange = (value: string) => { + if (store.addServer.adding) return + setStore("addServer", { url: value, error: "" }) + void previewStatus(value, store.addServer.username, store.addServer.password, (next) => + setStore("addServer", { status: next }), + ) + } + + const handleAddNameChange = (value: string) => { + if (store.addServer.adding) return + setStore("addServer", { name: value, error: "" }) + } + + const handleAddUsernameChange = (value: string) => { + if (store.addServer.adding) return + setStore("addServer", { username: value, error: "" }) + void previewStatus(store.addServer.url, value, store.addServer.password, (next) => + setStore("addServer", { status: next }), + ) + } + + const handleAddPasswordChange = (value: string) => { + if (store.addServer.adding) return + setStore("addServer", { password: value, error: "" }) + void previewStatus(store.addServer.url, store.addServer.username, value, (next) => + setStore("addServer", { status: next }), + ) + } + + const handleEditChange = (value: string) => { + if (store.editServer.busy) return + setStore("editServer", { value, error: "" }) + void previewStatus(value, store.editServer.username, store.editServer.password, (next) => + setStore("editServer", { status: next }), + ) + } + + const handleEditNameChange = (value: string) => { + if (store.editServer.busy) return + setStore("editServer", { name: value, error: "" }) + } + + const handleEditUsernameChange = (value: string) => { + if (store.editServer.busy) return + setStore("editServer", { username: value, error: "" }) + void previewStatus(store.editServer.value, value, store.editServer.password, (next) => + setStore("editServer", { status: next }), + ) + } + + const handleEditPasswordChange = (value: string) => { + if (store.editServer.busy) return + setStore("editServer", { password: value, error: "" }) + void previewStatus(store.editServer.value, store.editServer.username, value, (next) => + setStore("editServer", { status: next }), + ) + } + + async function handleAdd(value: string) { + if (store.addServer.adding) return + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetAdd() + return + } + + setStore("addServer", { adding: true, error: "" }) + + const conn: ServerConnection.Http = { + type: "http", + http: { url: normalized }, + } + if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() + if (store.addServer.username) conn.http.username = store.addServer.username + if (store.addServer.password) conn.http.password = store.addServer.password + const result = await checkServerHealth(conn.http, fetcher) + setStore("addServer", { adding: false }) + if (!result.healthy) { + setStore("addServer", { error: language.t("dialog.server.add.error") }) + return + } + + resetAdd() + await select(conn, true) + } + + async function handleEdit(original: ServerConnection.Any, value: string) { + if (store.editServer.busy || original.type !== "http") return + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetEdit() + return + } + + const name = store.editServer.name.trim() || undefined + const username = store.editServer.username || undefined + const password = store.editServer.password || undefined + const existingName = original.displayName + if ( + normalized === original.http.url && + name === existingName && + username === original.http.username && + password === original.http.password + ) { + resetEdit() + return + } + + setStore("editServer", { busy: true, error: "" }) + + const conn: ServerConnection.Http = { + type: "http", + displayName: name, + http: { url: normalized, username, password }, + } + const result = await checkServerHealth(conn.http, fetcher) + setStore("editServer", { busy: false }) + if (!result.healthy) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } + if (normalized === original.http.url) { + server.add(conn) + } else { + replaceServer(original, conn) + } + + resetEdit() + } + + const mode = createMemo<"list" | "add" | "edit">(() => { + if (store.editServer.id) return "edit" + if (store.addServer.showForm) return "add" + return "list" + }) + + const editing = createMemo(() => { + if (!store.editServer.id) return + return items().find((x) => x.type === "http" && x.http.url === store.editServer.id) + }) + + const resetForm = () => { + resetAdd() + resetEdit() + } + + const startAdd = () => { + resetEdit() + setStore("addServer", { + showForm: true, + url: "", + name: "", + username: "", + password: "", + error: "", + status: undefined, + }) + } + + const startEdit = (conn: ServerConnection.Http) => { + resetAdd() + setStore("editServer", { + id: conn.http.url, + value: conn.http.url, + name: conn.displayName ?? "", + username: conn.http.username ?? "", + password: conn.http.password ?? "", + error: "", + status: store.status[ServerConnection.key(conn)]?.healthy, + busy: false, + }) + } + + const submitForm = () => { + if (mode() === "add") { + void handleAdd(store.addServer.url) + return + } + const original = editing() + if (!original) return + void handleEdit(original, store.editServer.value) + } + + const isFormMode = createMemo(() => mode() !== "list") + const isAddMode = createMemo(() => mode() === "add") + const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy)) + + const formTitle = createMemo(() => { + if (!isFormMode()) return language.t("dialog.server.title") + return ( +
+ + {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} +
+ ) + }) + + createEffect(() => { + if (!store.editServer.id) return + if (editing()) return + resetEdit() + }) + + async function handleRemove(url: ServerConnection.Key) { + server.remove(url) + if ((await platform.getDefaultServerUrl?.()) === url) { + platform.setDefaultServerUrl?.(null) + } + } + + return ( + +
+ + } + > + x.http.url} + onSelect={(x) => { + if (x) select(x) + }} + divider={true} + class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" + > + {(i) => { + const key = ServerConnection.key(i) + return ( +
+
+ +
+ + + {language.t("dialog.server.status.default")} + + + } + showCredentials + /> +
+ + + + + + + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + { + if (i.type !== "http") return + startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + setDefault(i.http.url)}> + + {language.t("dialog.server.menu.default")} + + + + + setDefault(null)}> + + {language.t("dialog.server.menu.defaultRemove")} + + + + + handleRemove(ServerConnection.key(i))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + {language.t("dialog.server.menu.delete")} + + + + + +
+
+ ) + }} +
+
+ +
+ + {language.t("dialog.server.add.button")} + + } + > + + +
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/dialog-settings.tsx b/Agent-backend/packages/app/src/components/dialog-settings.tsx new file mode 100644 index 000000000..83cea131f --- /dev/null +++ b/Agent-backend/packages/app/src/components/dialog-settings.tsx @@ -0,0 +1,73 @@ +import { Component } from "solid-js" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Tabs } from "@opencode-ai/ui/tabs" +import { Icon } from "@opencode-ai/ui/icon" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { SettingsGeneral } from "./settings-general" +import { SettingsKeybinds } from "./settings-keybinds" +import { SettingsProviders } from "./settings-providers" +import { SettingsModels } from "./settings-models" + +export const DialogSettings: Component = () => { + const language = useLanguage() + const platform = usePlatform() + + return ( + + + +
+
+
+
+ {language.t("settings.section.desktop")} +
+ + + {language.t("settings.tab.general")} + + + + {language.t("settings.tab.shortcuts")} + +
+
+ +
+ {language.t("settings.section.server")} +
+ + + {language.t("settings.providers.title")} + + + + {language.t("settings.models.title")} + +
+
+
+
+
+ {language.t("app.name.desktop")} + v{platform.version} +
+
+
+ + + + + + + + + + + + +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/file-tree.test.ts b/Agent-backend/packages/app/src/components/file-tree.test.ts new file mode 100644 index 000000000..29e20b480 --- /dev/null +++ b/Agent-backend/packages/app/src/components/file-tree.test.ts @@ -0,0 +1,78 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" + +let shouldListRoot: typeof import("./file-tree").shouldListRoot +let shouldListExpanded: typeof import("./file-tree").shouldListExpanded +let dirsToExpand: typeof import("./file-tree").dirsToExpand + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, + useParams: () => ({}), + })) + mock.module("@/context/file", () => ({ + useFile: () => ({ + tree: { + state: () => undefined, + list: () => Promise.resolve(), + children: () => [], + expand: () => {}, + collapse: () => {}, + }, + }), + })) + mock.module("@opencode-ai/ui/collapsible", () => ({ + Collapsible: { + Trigger: (props: { children?: unknown }) => props.children, + Content: (props: { children?: unknown }) => props.children, + }, + })) + mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null })) + mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null })) + mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children })) + const mod = await import("./file-tree") + shouldListRoot = mod.shouldListRoot + shouldListExpanded = mod.shouldListExpanded + dirsToExpand = mod.dirsToExpand +}) + +describe("file tree fetch discipline", () => { + test("root lists on mount unless already loaded or loading", () => { + expect(shouldListRoot({ level: 0 })).toBe(true) + expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false) + expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false) + expect(shouldListRoot({ level: 1 })).toBe(false) + }) + + test("nested dirs list only when expanded and stale", () => { + expect(shouldListExpanded({ level: 1 })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true) + expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false) + expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false) + }) + + test("allowed auto-expand picks only collapsed dirs", () => { + const expanded = new Set() + const filter = { dirs: new Set(["src", "src/components"]) } + + const first = dirsToExpand({ + level: 0, + filter, + expanded: (dir) => expanded.has(dir), + }) + + expect(first).toEqual(["src", "src/components"]) + + for (const dir of first) expanded.add(dir) + + const second = dirsToExpand({ + level: 0, + filter, + expanded: (dir) => expanded.has(dir), + }) + + expect(second).toEqual([]) + expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([]) + }) +}) diff --git a/Agent-backend/packages/app/src/components/file-tree.tsx b/Agent-backend/packages/app/src/components/file-tree.tsx new file mode 100644 index 000000000..930832fb6 --- /dev/null +++ b/Agent-backend/packages/app/src/components/file-tree.tsx @@ -0,0 +1,507 @@ +import { useFile } from "@/context/file" +import { encodeFilePath } from "@/context/file/path" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { + createEffect, + createMemo, + For, + Match, + on, + Show, + splitProps, + Switch, + untrack, + type ComponentProps, + type JSXElement, + type ParentProps, +} from "solid-js" +import { Dynamic } from "solid-js/web" +import type { FileNode } from "@opencode-ai/sdk/v2" + +const MAX_DEPTH = 128 + +function pathToFileUrl(filepath: string): string { + return `file://${encodeFilePath(filepath)}` +} + +type Kind = "add" | "del" | "mix" + +type Filter = { + files: Set + dirs: Set +} + +export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) { + if (input.level !== 0) return false + if (input.dir?.loaded) return false + if (input.dir?.loading) return false + return true +} + +export function shouldListExpanded(input: { + level: number + dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean } +}) { + if (input.level === 0) return false + if (!input.dir?.expanded) return false + if (input.dir.loaded) return false + if (input.dir.loading) return false + return true +} + +export function dirsToExpand(input: { + level: number + filter?: { dirs: Set } + expanded: (dir: string) => boolean +}) { + if (input.level !== 0) return [] + if (!input.filter) return [] + return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) +} + +const kindLabel = (kind: Kind) => { + if (kind === "add") return "A" + if (kind === "del") return "D" + return "M" +} + +const kindTextColor = (kind: Kind) => { + if (kind === "add") return "color: var(--icon-diff-add-base)" + if (kind === "del") return "color: var(--icon-diff-delete-base)" + return "color: var(--icon-diff-modified-base)" +} + +const kindDotColor = (kind: Kind) => { + if (kind === "add") return "background-color: var(--icon-diff-add-base)" + if (kind === "del") return "background-color: var(--icon-diff-delete-base)" + return "background-color: var(--icon-diff-modified-base)" +} + +const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { + const kind = kinds?.get(node.path) + if (!kind) return + if (!marks?.has(node.path)) return + return kind +} + +const buildDragImage = (target: HTMLElement) => { + const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") + const text = target.querySelector("span") + if (!icon || !text) return + + const image = document.createElement("div") + image.className = + "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" + image.style.position = "absolute" + image.style.top = "-1000px" + image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML + return image +} + +const withFileDragImage = (event: DragEvent) => { + const image = buildDragImage(event.currentTarget as HTMLElement) + if (!image) return + document.body.appendChild(image) + event.dataTransfer?.setDragImage(image, 0, 12) + setTimeout(() => document.body.removeChild(image), 0) +} + +const FileTreeNode = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + level: number + active?: string + nodeClass?: string + draggable: boolean + kinds?: ReadonlyMap + marks?: Set + as?: "div" | "button" + }, +) => { + const [local, rest] = splitProps(p, [ + "node", + "level", + "active", + "nodeClass", + "draggable", + "kinds", + "marks", + "as", + "children", + "class", + "classList", + ]) + const kind = () => visibleKind(local.node, local.kinds, local.marks) + const active = () => !!kind() && !local.node.ignored + const color = () => { + const value = kind() + if (!value) return + return kindTextColor(value) + } + + return ( + { + if (!local.draggable) return + event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" + withFileDragImage(event) + }} + {...rest} + > + {local.children} + + {local.node.name} + + {(() => { + const value = kind() + if (!value) return null + if (local.node.type === "file") { + return ( + + {kindLabel(value)} + + ) + } + return
+ })()} + + ) +} + +export default function FileTree(props: { + path: string + class?: string + nodeClass?: string + active?: string + level?: number + allowed?: readonly string[] + modified?: readonly string[] + kinds?: ReadonlyMap + draggable?: boolean + onFileClick?: (file: FileNode) => void + + _filter?: Filter + _marks?: Set + _deeps?: Map + _kinds?: ReadonlyMap + _chain?: readonly string[] +}) { + const file = useFile() + const level = props.level ?? 0 + const draggable = () => props.draggable ?? true + + const key = (p: string) => + file + .normalize(p) + .replace(/[\\/]+$/, "") + .replaceAll("\\", "/") + const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] + + const filter = createMemo(() => { + if (props._filter) return props._filter + + const allowed = props.allowed + if (!allowed) return + + const files = new Set(allowed) + const dirs = new Set() + + for (const item of allowed) { + const parts = item.split("/") + const parents = parts.slice(0, -1) + for (const [idx] of parents.entries()) { + const dir = parents.slice(0, idx + 1).join("/") + if (dir) dirs.add(dir) + } + } + + return { files, dirs } + }) + + const marks = createMemo(() => { + if (props._marks) return props._marks + + const out = new Set() + for (const item of props.modified ?? []) out.add(item) + for (const item of props.kinds?.keys() ?? []) out.add(item) + if (out.size === 0) return + return out + }) + + const kinds = createMemo(() => { + if (props._kinds) return props._kinds + return props.kinds + }) + + const deeps = createMemo(() => { + if (props._deeps) return props._deeps + + const out = new Map() + + const root = props.path + if (!(file.tree.state(root)?.expanded ?? false)) return out + + const seen = new Set() + const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] + + const push = (dir: string, lvl: number) => { + const id = key(dir) + if (seen.has(id)) return + seen.add(id) + + const kids = file.tree + .children(dir) + .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false)) + .map((node) => node.path) + + stack.push({ dir, lvl, i: 0, kids, max: lvl }) + } + + push(root, level - 1) + + while (stack.length > 0) { + const top = stack[stack.length - 1]! + + if (top.i < top.kids.length) { + const next = top.kids[top.i]! + top.i++ + push(next, top.lvl + 1) + continue + } + + out.set(top.dir, top.max) + stack.pop() + + const parent = stack[stack.length - 1] + if (!parent) continue + parent.max = Math.max(parent.max, top.max) + } + + return out + }) + + createEffect(() => { + const current = filter() + const dirs = dirsToExpand({ + level, + filter: current, + expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, + }) + for (const dir of dirs) file.tree.expand(dir) + }) + + createEffect( + on( + () => props.path, + (path) => { + const dir = untrack(() => file.tree.state(path)) + if (!shouldListRoot({ level, dir })) return + void file.tree.list(path) + }, + { defer: false }, + ), + ) + + const nodes = createMemo(() => { + const nodes = file.tree.children(props.path) + const current = filter() + if (!current) return nodes + + const parent = (path: string) => { + const idx = path.lastIndexOf("/") + if (idx === -1) return "" + return path.slice(0, idx) + } + + const leaf = (path: string) => { + const idx = path.lastIndexOf("/") + return idx === -1 ? path : path.slice(idx + 1) + } + + const out = nodes.filter((node) => { + if (node.type === "file") return current.files.has(node.path) + return current.dirs.has(node.path) + }) + + const seen = new Set(out.map((node) => node.path)) + + for (const dir of current.dirs) { + if (parent(dir) !== props.path) continue + if (seen.has(dir)) continue + out.push({ + name: leaf(dir), + path: dir, + absolute: dir, + type: "directory", + ignored: false, + }) + seen.add(dir) + } + + for (const item of current.files) { + if (parent(item) !== props.path) continue + if (seen.has(item)) continue + out.push({ + name: leaf(item), + path: item, + absolute: item, + type: "file", + ignored: false, + }) + seen.add(item) + } + + out.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + return out + }) + + return ( +
+ + {(node) => { + const expanded = () => file.tree.state(node.path)?.expanded ?? false + const deep = () => deeps().get(node.path) ?? -1 + const kind = () => visibleKind(node, kinds(), marks()) + const active = () => !!kind() && !node.ignored + + return ( + + + (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} + > + + +
+ +
+
+
+ +
+ ...
} + > + + +
+
+
+ + props.onFileClick?.(node)} + > +
+ + + + + + + + + + + + + + + + + + ) + }} + +
+ ) +} diff --git a/Agent-backend/packages/app/src/components/link.tsx b/Agent-backend/packages/app/src/components/link.tsx new file mode 100644 index 000000000..85f7efc53 --- /dev/null +++ b/Agent-backend/packages/app/src/components/link.tsx @@ -0,0 +1,26 @@ +import { ComponentProps, splitProps } from "solid-js" +import { usePlatform } from "@/context/platform" + +export interface LinkProps extends Omit, "href"> { + href: string +} + +export function Link(props: LinkProps) { + const platform = usePlatform() + const [local, rest] = splitProps(props, ["href", "children", "class"]) + + return ( + { + if (!local.href) return + event.preventDefault() + platform.openLink(local.href) + }} + {...rest} + > + {local.children} + + ) +} diff --git a/Agent-backend/packages/app/src/components/model-tooltip.tsx b/Agent-backend/packages/app/src/components/model-tooltip.tsx new file mode 100644 index 000000000..53164dae8 --- /dev/null +++ b/Agent-backend/packages/app/src/components/model-tooltip.tsx @@ -0,0 +1,91 @@ +import { Show, type Component } from "solid-js" +import { useLanguage } from "@/context/language" + +type InputKey = "text" | "image" | "audio" | "video" | "pdf" +type InputMap = Record + +type ModelInfo = { + id: string + name: string + provider: { + name: string + } + capabilities?: { + reasoning: boolean + input: InputMap + } + modalities?: { + input: Array + } + reasoning?: boolean + limit: { + context: number + } +} + +export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => { + const language = useLanguage() + const sourceName = (model: ModelInfo) => { + const value = `${model.id} ${model.name}`.toLowerCase() + + if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic") + if (/gpt|o[1-4]|codex|openai/.test(value)) return language.t("model.provider.openai") + if (/gemini|palm|bard|google/.test(value)) return language.t("model.provider.google") + if (/grok|xai/.test(value)) return language.t("model.provider.xai") + if (/llama|meta/.test(value)) return language.t("model.provider.meta") + + return model.provider.name + } + const inputLabel = (value: string) => { + if (value === "text") return language.t("model.input.text") + if (value === "image") return language.t("model.input.image") + if (value === "audio") return language.t("model.input.audio") + if (value === "video") return language.t("model.input.video") + if (value === "pdf") return language.t("model.input.pdf") + return value + } + const title = () => { + const tags: Array = [] + if (props.latest) tags.push(language.t("model.tag.latest")) + if (props.free) tags.push(language.t("model.tag.free")) + const suffix = tags.length ? ` (${tags.join(", ")})` : "" + return `${sourceName(props.model)} ${props.model.name}${suffix}` + } + const inputs = () => { + if (props.model.capabilities) { + const input = props.model.capabilities.input + const order: Array = ["text", "image", "audio", "video", "pdf"] + const entries = order.filter((key) => input[key]).map((key) => inputLabel(key)) + return entries.length ? entries.join(", ") : undefined + } + const raw = props.model.modalities?.input + if (!raw) return + const entries = raw.map((value) => inputLabel(value)) + return entries.length ? entries.join(", ") : undefined + } + const reasoning = () => { + if (props.model.capabilities) + return props.model.capabilities.reasoning + ? language.t("model.tooltip.reasoning.allowed") + : language.t("model.tooltip.reasoning.none") + return props.model.reasoning + ? language.t("model.tooltip.reasoning.allowed") + : language.t("model.tooltip.reasoning.none") + } + const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() }) + + return ( +
+
{title()}
+ + {(value) => ( +
+ {language.t("model.tooltip.allows", { inputs: value() })} +
+ )} +
+
{reasoning()}
+
{context()}
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/prompt-input.tsx b/Agent-backend/packages/app/src/components/prompt-input.tsx new file mode 100644 index 000000000..532edd3bc --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input.tsx @@ -0,0 +1,1534 @@ +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" +import { createStore } from "solid-js/store" +import { createFocusSignal } from "@solid-primitives/active-element" +import { useLocal } from "@/context/local" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" +import { + ContentPart, + DEFAULT_PROMPT, + isPromptEqual, + Prompt, + usePrompt, + ImageAttachmentPart, + AgentPart, + FileAttachmentPart, +} from "@/context/prompt" +import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" +import { useParams } from "@solidjs/router" +import { useSync } from "@/context/sync" +import { useComments } from "@/context/comments" +import { Button } from "@opencode-ai/ui/button" +import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" +import { Icon } from "@opencode-ai/ui/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Select } from "@opencode-ai/ui/select" +import { RadioGroup } from "@opencode-ai/ui/radio-group" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ModelSelectorPopover } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" +import { useProviders } from "@/hooks/use-providers" +import { useCommand } from "@/context/command" +import { Persist, persisted } from "@/utils/persist" +import { usePermission } from "@/context/permission" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" +import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" +import { + canNavigateHistoryAtCursor, + navigatePromptHistory, + prependHistoryEntry, + type PromptHistoryComment, + type PromptHistoryEntry, + type PromptHistoryStoredEntry, + promptLength, +} from "./prompt-input/history" +import { createPromptSubmit } from "./prompt-input/submit" +import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" +import { PromptContextItems } from "./prompt-input/context-items" +import { PromptImageAttachments } from "./prompt-input/image-attachments" +import { PromptDragOverlay } from "./prompt-input/drag-overlay" +import { promptPlaceholder } from "./prompt-input/placeholder" +import { ImagePreview } from "@opencode-ai/ui/image-preview" + +interface PromptInputProps { + class?: string + ref?: (el: HTMLDivElement) => void + newSessionWorktree?: string + onNewSessionWorktreeReset?: () => void + onSubmit?: () => void +} + +const EXAMPLES = [ + "prompt.example.1", + "prompt.example.2", + "prompt.example.3", + "prompt.example.4", + "prompt.example.5", + "prompt.example.6", + "prompt.example.7", + "prompt.example.8", + "prompt.example.9", + "prompt.example.10", + "prompt.example.11", + "prompt.example.12", + "prompt.example.13", + "prompt.example.14", + "prompt.example.15", + "prompt.example.16", + "prompt.example.17", + "prompt.example.18", + "prompt.example.19", + "prompt.example.20", + "prompt.example.21", + "prompt.example.22", + "prompt.example.23", + "prompt.example.24", + "prompt.example.25", +] as const + +const NON_EMPTY_TEXT = /[^\s\u200B]/ + +export const PromptInput: Component = (props) => { + const sdk = useSDK() + const sync = useSync() + const local = useLocal() + const files = useFile() + const prompt = usePrompt() + const layout = useLayout() + const comments = useComments() + const params = useParams() + const dialog = useDialog() + const providers = useProviders() + const command = useCommand() + const permission = usePermission() + const language = useLanguage() + const platform = usePlatform() + let editorRef!: HTMLDivElement + let fileInputRef: HTMLInputElement | undefined + let scrollRef!: HTMLDivElement + let slashPopoverRef!: HTMLDivElement + + const mirror = { input: false } + const inset = 44 + + const scrollCursorIntoView = () => { + const container = scrollRef + const selection = window.getSelection() + if (!container || !selection || selection.rangeCount === 0) return + + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return + + const cursor = getCursorPosition(editorRef) + const length = promptLength(prompt.current().filter((part) => part.type !== "image")) + if (cursor >= length) { + container.scrollTop = container.scrollHeight + return + } + + const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect() + if (!rect.height) return + + const containerRect = container.getBoundingClientRect() + const top = rect.top - containerRect.top + container.scrollTop + const bottom = rect.bottom - containerRect.top + container.scrollTop + const padding = 12 + + if (top < container.scrollTop + padding) { + container.scrollTop = Math.max(0, top - padding) + return + } + + if (bottom > container.scrollTop + container.clientHeight - inset) { + container.scrollTop = bottom - container.clientHeight + inset + } + } + + const queueScroll = () => { + requestAnimationFrame(scrollCursorIntoView) + } + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + + const commentInReview = (path: string) => { + const sessionID = params.id + if (!sessionID) return false + + const diffs = sync.data.session_diff[sessionID] + if (!diffs) return false + return diffs.some((diff) => diff.file === path) + } + + const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => { + if (!item.commentID) return + + const focus = { file: item.path, id: item.commentID } + comments.setActive(focus) + + const queueCommentFocus = (attempts = 6) => { + const schedule = (left: number) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (left <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (!current) return + if (current.file !== focus.file || current.id !== focus.id) return + schedule(left - 1) + }) + }) + } + + schedule(attempts) + } + + const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) + if (wantsReview) { + if (!view().reviewPanel.opened()) view().reviewPanel.open() + layout.fileTree.setTab("changes") + tabs().setActive("review") + queueCommentFocus() + return + } + + if (!view().reviewPanel.opened()) view().reviewPanel.open() + layout.fileTree.setTab("all") + const tab = files.tab(item.path) + tabs().open(tab) + tabs().setActive(tab) + Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) + } + + const recent = createMemo(() => { + const all = tabs().all() + const active = tabs().active() + const order = active ? [active, ...all.filter((x) => x !== active)] : all + const seen = new Set() + const paths: string[] = [] + + for (const tab of order) { + const path = files.pathFromTab(tab) + if (!path) continue + if (seen.has(path)) continue + seen.add(path) + paths.push(path) + } + + return paths + }) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") + const imageAttachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + ) + + const [store, setStore] = createStore<{ + popover: "at" | "slash" | null + historyIndex: number + savedPrompt: PromptHistoryEntry | null + placeholder: number + draggingType: "image" | "@mention" | null + mode: "normal" | "shell" + applyingHistory: boolean + }>({ + popover: null, + historyIndex: -1, + savedPrompt: null as PromptHistoryEntry | null, + placeholder: Math.floor(Math.random() * EXAMPLES.length), + draggingType: null, + mode: "normal", + applyingHistory: false, + }) + + const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) + + const commentCount = createMemo(() => { + if (store.mode === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + + const contextItems = createMemo(() => { + const items = prompt.context.items() + if (store.mode !== "shell") return items + return items.filter((item) => !item.comment?.trim()) + }) + + const hasUserPrompt = createMemo(() => { + const sessionID = params.id + if (!sessionID) return false + const messages = sync.data.message[sessionID] + if (!messages) return false + return messages.some((m) => m.role === "user") + }) + + const [history, setHistory] = persisted( + Persist.global("prompt-history", ["prompt-history.v1"]), + createStore<{ + entries: PromptHistoryStoredEntry[] + }>({ + entries: [], + }), + ) + const [shellHistory, setShellHistory] = persisted( + Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), + createStore<{ + entries: PromptHistoryStoredEntry[] + }>({ + entries: [], + }), + ) + + const suggest = createMemo(() => !hasUserPrompt()) + + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "", + suggest: suggest(), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + if (item.type !== "file") return [] + const comment = item.comment?.trim() + if (!comment) return [] + + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ + start: item.selection.startLine, + end: item.selection.endLine, + } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + + const applyHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file" as const, + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => { + const p = entry.prompt + const length = position === "start" ? 0 : promptLength(p) + setStore("applyingHistory", true) + applyHistoryComments(entry.comments) + prompt.set(p, length) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, length) + setStore("applyingHistory", false) + queueScroll() + }) + } + + const getCaretState = () => { + const selection = window.getSelection() + const textLength = promptLength(prompt.current()) + if (!selection || selection.rangeCount === 0) { + return { collapsed: false, cursorPosition: 0, textLength } + } + const anchorNode = selection.anchorNode + if (!anchorNode || !editorRef.contains(anchorNode)) { + return { collapsed: false, cursorPosition: 0, textLength } + } + return { + collapsed: selection.isCollapsed, + cursorPosition: getCursorPosition(editorRef), + textLength, + } + } + + const isFocused = createFocusSignal(() => editorRef) + const escBlur = () => platform.platform === "desktop" && platform.os === "macos" + + const pick = () => fileInputRef?.click() + + const setMode = (mode: "normal" | "shell") => { + setStore("mode", mode) + setStore("popover", null) + requestAnimationFrame(() => editorRef?.focus()) + } + + const shellModeKey = "mod+shift+x" + const normalModeKey = "mod+shift+e" + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: store.mode !== "normal", + onSelect: pick, + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: shellModeKey, + disabled: store.mode === "shell", + onSelect: () => setMode("shell"), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: normalModeKey, + disabled: store.mode === "normal", + onSelect: () => setMode("normal"), + }, + ]) + + const closePopover = () => setStore("popover", null) + + const resetHistoryNavigation = (force = false) => { + if (!force && (store.historyIndex < 0 || store.applyingHistory)) return + setStore("historyIndex", -1) + setStore("savedPrompt", null) + } + + const clearEditor = () => { + editorRef.innerHTML = "" + } + + const setEditorText = (text: string) => { + clearEditor() + editorRef.textContent = text + } + + const focusEditorEnd = () => { + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const selection = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + }) + } + + const currentCursor = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null + return getCursorPosition(editorRef) + } + + const renderEditorWithCursor = (parts: Prompt) => { + const cursor = currentCursor() + renderEditor(parts) + if (cursor !== null) setCursorPosition(editorRef, cursor) + } + + createEffect(() => { + params.id + if (params.id) return + if (!suggest()) return + const interval = setInterval(() => { + setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) + }, 6500) + onCleanup(() => clearInterval(interval)) + }) + + const [composing, setComposing] = createSignal(false) + const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 + + const handleBlur = () => { + closePopover() + setComposing(false) + } + + const agentList = createMemo(() => + sync.data.agent + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), + ) + const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) + + const handleAtSelect = (option: AtOption | undefined) => { + if (!option) return + if (option.type === "agent") { + addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) + } else { + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + } + } + + const atKey = (x: AtOption | undefined) => { + if (!x) return "" + return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` + } + + const { + flat: atFlat, + active: atActive, + setActive: setAtActive, + onInput: atOnInput, + onKeyDown: atOnKeyDown, + } = useFilteredList({ + items: async (query) => { + const agents = agentList() + const open = recent() + const seen = new Set(open) + const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + const paths = await files.searchFilesAndDirectories(query) + const fileOptions: AtOption[] = paths + .filter((path) => !seen.has(path)) + .map((path) => ({ type: "file", path, display: path })) + return [...agents, ...pinned, ...fileOptions] + }, + key: atKey, + filterKeys: ["display"], + groupBy: (item) => { + if (item.type === "agent") return "agent" + if (item.recent) return "recent" + return "file" + }, + sortGroupsBy: (a, b) => { + const rank = (category: string) => { + if (category === "agent") return 0 + if (category === "recent") return 1 + return 2 + } + return rank(a.category) - rank(b.category) + }, + onSelect: handleAtSelect, + }) + + const slashCommands = createMemo(() => { + const builtin = command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + type: "builtin" as const, + })) + + const custom = sync.data.command.map((cmd) => ({ + id: `custom.${cmd.name}`, + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + type: "custom" as const, + source: cmd.source, + })) + + return [...custom, ...builtin] + }) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + closePopover() + + if (cmd.type === "custom") { + const text = `/${cmd.trigger} ` + setEditorText(text) + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + focusEditorEnd() + return + } + + clearEditor() + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + command.trigger(cmd.id, "slash") + } + + const { + flat: slashFlat, + active: slashActive, + setActive: setSlashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + } = useFilteredList({ + items: slashCommands, + key: (x) => x?.id, + filterKeys: ["trigger", "title"], + onSelect: handleSlashSelect, + }) + + const createPill = (part: FileAttachmentPart | AgentPart) => { + const pill = document.createElement("span") + pill.textContent = part.content + pill.setAttribute("data-type", part.type) + if (part.type === "file") pill.setAttribute("data-path", part.path) + if (part.type === "agent") pill.setAttribute("data-name", part.name) + pill.setAttribute("contenteditable", "false") + pill.style.userSelect = "text" + pill.style.cursor = "default" + return pill + } + + const isNormalizedEditor = () => + Array.from(editorRef.childNodes).every((node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + return !!prevIsBr && !next + } + if (node.nodeType !== Node.ELEMENT_NODE) return false + const el = node as HTMLElement + if (el.dataset.type === "file") return true + if (el.dataset.type === "agent") return true + return el.tagName === "BR" + }) + + const renderEditor = (parts: Prompt) => { + clearEditor() + for (const part of parts) { + if (part.type === "text") { + editorRef.appendChild(createTextFragment(part.content)) + continue + } + if (part.type === "file" || part.type === "agent") { + editorRef.appendChild(createPill(part)) + } + } + + const last = editorRef.lastChild + if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") { + editorRef.appendChild(document.createTextNode("\u200B")) + } + } + + // Auto-scroll active command into view when navigating with keyboard + createEffect(() => { + const activeId = slashActive() + if (!activeId || !slashPopoverRef) return + + requestAnimationFrame(() => { + const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + }) + + const selectPopoverActive = () => { + if (store.popover === "at") { + const items = atFlat() + if (items.length === 0) return + const active = atActive() + const item = items.find((entry) => atKey(entry) === active) ?? items[0] + handleAtSelect(item) + return + } + + if (store.popover === "slash") { + const items = slashFlat() + if (items.length === 0) return + const active = slashActive() + const item = items.find((entry) => entry.id === active) ?? items[0] + handleSlashSelect(item) + } + } + + createEffect( + on( + () => prompt.current(), + (currentParts) => { + const inputParts = currentParts.filter((part) => part.type !== "image") + + if (mirror.input) { + mirror.input = false + if (isNormalizedEditor()) return + + renderEditorWithCursor(inputParts) + return + } + + const domParts = parseFromDOM() + if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return + + renderEditorWithCursor(inputParts) + }, + ), + ) + + const parseFromDOM = (): Prompt => { + const parts: Prompt = [] + let position = 0 + let buffer = "" + + const flushText = () => { + let content = buffer + if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n") + if (content.includes("\u200B")) content = content.replace(/\u200B/g, "") + buffer = "" + if (!content) return + parts.push({ type: "text", content, start: position, end: position + content.length }) + position += content.length + } + + const pushFile = (file: HTMLElement) => { + const content = file.textContent ?? "" + parts.push({ + type: "file", + path: file.dataset.path!, + content, + start: position, + end: position + content.length, + }) + position += content.length + } + + const pushAgent = (agent: HTMLElement) => { + const content = agent.textContent ?? "" + parts.push({ + type: "agent", + name: agent.dataset.name!, + content, + start: position, + end: position + content.length, + }) + position += content.length + } + + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + buffer += node.textContent ?? "" + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + + const el = node as HTMLElement + if (el.dataset.type === "file") { + flushText() + pushFile(el) + return + } + if (el.dataset.type === "agent") { + flushText() + pushAgent(el) + return + } + if (el.tagName === "BR") { + buffer += "\n" + return + } + + for (const child of Array.from(el.childNodes)) { + visit(child) + } + } + + const children = Array.from(editorRef.childNodes) + children.forEach((child, index) => { + const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) + visit(child) + if (isBlock && index < children.length - 1) { + buffer += "\n" + } + }) + + flushText() + + if (parts.length === 0) parts.push(...DEFAULT_PROMPT) + return parts + } + + const handleInput = () => { + const rawParts = parseFromDOM() + const images = imageAttachments() + const cursorPosition = getCursorPosition(editorRef) + const rawText = + rawParts.length === 1 && rawParts[0]?.type === "text" + ? rawParts[0].content + : rawParts.map((p) => ("content" in p ? p.content : "")).join("") + const hasNonText = rawParts.some((part) => part.type !== "text") + const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0 + + if (shouldReset) { + closePopover() + resetHistoryNavigation() + if (prompt.dirty()) { + mirror.input = true + prompt.set(DEFAULT_PROMPT, 0) + } + queueScroll() + return + } + + const shellMode = store.mode === "shell" + + if (!shellMode) { + const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + const slashMatch = rawText.match(/^\/(\S*)$/) + + if (atMatch) { + atOnInput(atMatch[1]) + setStore("popover", "at") + } else if (slashMatch) { + slashOnInput(slashMatch[1]) + setStore("popover", "slash") + } else { + closePopover() + } + } else { + closePopover() + } + + resetHistoryNavigation() + + mirror.input = true + prompt.set([...rawParts, ...images], cursorPosition) + queueScroll() + } + + const addPart = (part: ContentPart) => { + if (part.type === "image") return false + + const selection = window.getSelection() + if (!selection) return false + + if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) { + editorRef.focus() + const cursor = prompt.cursor() ?? promptLength(prompt.current()) + setCursorPosition(editorRef, cursor) + } + + if (selection.rangeCount === 0) return false + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return false + + if (part.type === "file" || part.type === "agent") { + const cursorPosition = getCursorPosition(editorRef) + const rawText = prompt + .current() + .map((p) => ("content" in p ? p.content : "")) + .join("") + const textBeforeCursor = rawText.substring(0, cursorPosition) + const atMatch = textBeforeCursor.match(/@(\S*)$/) + const pill = createPill(part) + const gap = document.createTextNode(" ") + + if (atMatch) { + const start = atMatch.index ?? cursorPosition - atMatch[0].length + setRangeEdge(editorRef, range, "start", start) + setRangeEdge(editorRef, range, "end", cursorPosition) + } + + range.deleteContents() + range.insertNode(gap) + range.insertNode(pill) + range.setStartAfter(gap) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + if (part.type === "text") { + const fragment = createTextFragment(part.content) + const last = fragment.lastChild + range.deleteContents() + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR" + const next = last.nextSibling + const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === "" + if (isBreak && (!next || emptyText)) { + const placeholder = next && emptyText ? next : document.createTextNode("\u200B") + if (!next) last.parentNode?.insertBefore(placeholder, null) + placeholder.textContent = "\u200B" + range.setStart(placeholder, 0) + } else { + range.setStartAfter(last) + } + } + } + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + handleInput() + closePopover() + return true + } + + const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { + const currentHistory = mode === "shell" ? shellHistory : history + const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory + const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments()) + if (next === currentHistory.entries) return + setCurrentHistory("entries", next) + } + + const navigateHistory = (direction: "up" | "down") => { + const result = navigatePromptHistory({ + direction, + entries: store.mode === "shell" ? shellHistory.entries : history.entries, + historyIndex: store.historyIndex, + currentPrompt: prompt.current(), + currentComments: historyComments(), + savedPrompt: store.savedPrompt, + }) + if (!result.handled) return false + setStore("historyIndex", result.historyIndex) + setStore("savedPrompt", result.savedPrompt) + applyHistoryPrompt(result.entry, result.cursor) + return true + } + + const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({ + editor: () => editorRef, + isFocused, + isDialogActive: () => !!dialog.active, + setDraggingType: (type) => setStore("draggingType", type), + focusEditor: () => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(prompt.current())) + }, + addPart, + readClipboardImage: platform.readClipboardImage, + }) + + const variants = createMemo(() => ["default", ...local.model.variant.list()]) + const accepting = createMemo(() => { + const id = params.id + if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) + return permission.isAutoAccepting(id, sdk.directory) + }) + + const { abort, handleSubmit } = createPromptSubmit({ + info, + imageAttachments, + commentCount, + autoAccept: () => accepting(), + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + resetHistoryNavigation(true) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => setStore("popover", popover), + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + onSubmit: props.onSubmit, + }) + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { + event.preventDefault() + if (store.mode !== "normal") return + pick() + return + } + + if (event.key === "Backspace") { + const selection = window.getSelection() + if (selection && selection.isCollapsed) { + const node = selection.anchorNode + const offset = selection.anchorOffset + if (node && node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (/^\u200B+$/.test(text) && offset > 0) { + const range = document.createRange() + range.setStart(node, 0) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + } + } + } + + if (event.key === "!" && store.mode === "normal") { + const cursorPosition = getCursorPosition(editorRef) + if (cursorPosition === 0) { + setStore("mode", "shell") + setStore("popover", null) + event.preventDefault() + return + } + } + + if (event.key === "Escape") { + if (store.popover) { + closePopover() + event.preventDefault() + event.stopPropagation() + return + } + + if (store.mode === "shell") { + setStore("mode", "normal") + event.preventDefault() + event.stopPropagation() + return + } + + if (working()) { + abort() + event.preventDefault() + event.stopPropagation() + return + } + + if (escBlur()) { + editorRef.blur() + event.preventDefault() + event.stopPropagation() + return + } + } + + if (store.mode === "shell") { + const { collapsed, cursorPosition, textLength } = getCaretState() + if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { + setStore("mode", "normal") + event.preventDefault() + return + } + } + + // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input + // and should always insert a newline regardless of composition state + if (event.key === "Enter" && event.shiftKey) { + addPart({ type: "text", content: "\n", start: 0, end: 0 }) + event.preventDefault() + return + } + + if (event.key === "Enter" && isImeComposing(event)) { + return + } + + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + + if (store.popover) { + if (event.key === "Tab") { + selectPopoverActive() + event.preventDefault() + return + } + const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" + const ctrlNav = ctrl && (event.key === "n" || event.key === "p") + if (nav || ctrlNav) { + if (store.popover === "at") { + atOnKeyDown(event) + event.preventDefault() + return + } + if (store.popover === "slash") { + slashOnKeyDown(event) + } + event.preventDefault() + return + } + } + + if (ctrl && event.code === "KeyG") { + if (store.popover) { + closePopover() + event.preventDefault() + return + } + if (working()) { + abort() + event.preventDefault() + } + return + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + if (event.altKey || event.ctrlKey || event.metaKey) return + const { collapsed } = getCaretState() + if (!collapsed) return + + const cursorPosition = getCursorPosition(editorRef) + const textContent = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + const direction = event.key === "ArrowUp" ? "up" : "down" + if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return + if (navigateHistory(direction)) { + event.preventDefault() + } + return + } + + // Note: Shift+Enter is handled earlier, before IME check + if (event.key === "Enter" && !event.shiftKey) { + handleSubmit(event) + } + } + + return ( +
+ (slashPopoverRef = el)} + atFlat={atFlat()} + atActive={atActive() ?? undefined} + atKey={atKey} + setAtActive={setAtActive} + onAtSelect={handleAtSelect} + slashFlat={slashFlat()} + slashActive={slashActive() ?? undefined} + setSlashActive={setSlashActive} + onSlashSelect={handleSlashSelect} + commandKeybind={command.keybind} + t={(key) => language.t(key as Parameters[0])} + /> + + + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeImageAttachment} + removeLabel={language.t("prompt.attachment.remove")} + /> +
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if ( + target.closest( + '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', + ) + ) { + return + } + editorRef?.focus() + }} + > +
(scrollRef = el)}> +
{ + editorRef = el + props.ref?.(el) + }} + role="textbox" + aria-multiline="true" + aria-label={placeholder()} + contenteditable="true" + autocapitalize={store.mode === "normal" ? "sentences" : "off"} + autocorrect={store.mode === "normal" ? "on" : "off"} + spellcheck={store.mode === "normal"} + onInput={handleInput} + onPaste={handlePaste} + onCompositionStart={() => setComposing(true)} + onCompositionEnd={() => setComposing(false)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + classList={{ + "select-text": true, + "w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "[&_[data-type=file]]:text-syntax-property": true, + "[&_[data-type=agent]]:text-syntax-type": true, + "font-mono!": store.mode === "shell", + }} + /> + +
+ {placeholder()} +
+
+
+ +
+ { + const file = e.currentTarget.files?.[0] + if (file) addImageAttachment(file) + e.currentTarget.value = "" + }} + /> + +
0.5 ? "auto" : "none", + }} + > + + + + + + +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+
+ +
+ {language.t("prompt.action.send")} + +
+
+ + } + > + +
+
+
+ +
+
+ + + +
+
+
+ + + +
+
+
+ {language.t("prompt.mode.shell")} +
+
+
+ + (x === "default" ? language.t("common.default") : x)} + onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} + class="capitalize max-w-[160px]" + valueClass="truncate text-13-regular" + triggerStyle={{ + height: "28px", + opacity: buttonsSpring(), + transform: `scale(${0.95 + buttonsSpring() * 0.05})`, + filter: `blur(${(1 - buttonsSpring()) * 2}px)`, + "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", + }} + variant="ghost" + /> + +
+
+
+ mode} + label={(mode) => ( + + + + )} + onSelect={(mode) => mode && setMode(mode)} + fill + pad="none" + class="w-[68px]" + /> +
+
+ + +
+ ) +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/CLAUDE.md b/Agent-backend/packages/app/src/components/prompt-input/CLAUDE.md new file mode 100644 index 000000000..c60933f1b --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/CLAUDE.md @@ -0,0 +1,44 @@ +# prompt-input + +> **Path:** `Agent-backend/packages/app/src/components/prompt-input/` + +## Purpose + +Extracted logic modules for the SolidJS chat input component (`prompt-input.tsx`). Pure functions and small components covering submit orchestration, request part building, prompt history, ContentEditable DOM helpers, placeholder text, slash commands, and file/image attachments. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Submit logic, attachment handling, history navigation, and slash command patterns are useful references for Orbit's React input component | +| Orbit CLI | `not used` | CLI TUI has its own prompt in `packages/opencode/src/cli/cmd/tui/component/prompt/` | + +## Files + +| File | Purpose | Tests | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `submit.ts` | Submit orchestration — builds request parts, handles session creation, worktree setup, abort controllers, comment attachment, shell mode dispatch | `submit.test.ts` | +| `build-request-parts.ts` | Converts `Prompt` (content parts) into SDK `UserMessagePart[]` with base64-encoded images, file context, and comment metadata | `build-request-parts.test.ts` | +| `history.ts` | Prompt history navigation (up/down arrow), max 100 entries, cursor-position-aware direction detection | `history.test.ts` | +| `editor-dom.ts` | ContentEditable DOM helpers — `createTextFragment`, `getCursorPosition`, `setCursorPosition`, range edge clamping, node length calculation | `editor-dom.test.ts` | +| `placeholder.ts` | Dynamic placeholder text based on mode (normal/shell), comment count, and i18n | `placeholder.test.ts` | +| `attachments.ts` | File/image attachment processing — drag-and-drop, paste handling, large paste detection (8000+ chars or 120+ line breaks), accepted MIME types (PNG, JPEG, GIF, WebP, PDF) | | +| `slash-popover.tsx` | `@`-mention and `/`-slash command popover with fuzzy search and keyboard navigation | | +| `context-items.tsx` | Renders attached context items (files, selections) as removable chips | | +| `image-attachments.tsx` | Image attachment preview grid with remove action | | +| `drag-overlay.tsx` | Drag-and-drop overlay indicator | | + +## Dependencies + +- **SDK:** `@opencode-ai/sdk/v2/client` — `Message`, `Part`, `TextPartInput`, `FilePartInput`, `AgentPartInput` types +- **UI:** `@opencode-ai/ui/toast` — toast notifications +- **Util:** `@opencode-ai/util/encode` (base64), `@opencode-ai/util/path` (filename extraction) +- **SolidJS contexts:** `@/context/prompt`, `@/context/sdk`, `@/context/file`, `@/context/language`, `@/context/layout`, `@/context/sync`, `@/context/local`, `@/context/permission`, `@/context/global-sync` +- **Router:** `@solidjs/router` — `useNavigate`, `useParams` + +## Notes + +- **SolidJS-specific** — uses SolidJS `Accessor` types, `onMount`/`onCleanup` lifecycle, and context imports. Not directly portable to React. +- **Pure logic is testable** — `history.ts`, `placeholder.ts`, `editor-dom.ts`, and `build-request-parts.ts` are largely framework-agnostic in their core logic, with SolidJS types only at the boundary. +- **`submit.ts` manages a `pending` Map** — tracks in-flight prompts by session ID with abort controllers, preventing duplicate submissions. +- **Large paste detection** — `attachments.ts` detects large pastes (8000+ chars or 120+ line breaks) to warn users before inserting massive content into the ContentEditable editor. diff --git a/Agent-backend/packages/app/src/components/prompt-input/attachments.ts b/Agent-backend/packages/app/src/components/prompt-input/attachments.ts new file mode 100644 index 000000000..a9e4e4965 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/attachments.ts @@ -0,0 +1,180 @@ +import { onCleanup, onMount } from "solid-js" +import { showToast } from "@opencode-ai/ui/toast" +import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" +import { useLanguage } from "@/context/language" +import { uuid } from "@/utils/uuid" +import { getCursorPosition } from "./editor-dom" + +export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] +const LARGE_PASTE_CHARS = 8000 +const LARGE_PASTE_BREAKS = 120 + +function largePaste(text: string) { + if (text.length >= LARGE_PASTE_CHARS) return true + let breaks = 0 + for (const char of text) { + if (char !== "\n") continue + breaks += 1 + if (breaks >= LARGE_PASTE_BREAKS) return true + } + return false +} + +type PromptAttachmentsInput = { + editor: () => HTMLDivElement | undefined + isFocused: () => boolean + isDialogActive: () => boolean + setDraggingType: (type: "image" | "@mention" | null) => void + focusEditor: () => void + addPart: (part: ContentPart) => boolean + readClipboardImage?: () => Promise +} + +export function createPromptAttachments(input: PromptAttachmentsInput) { + const prompt = usePrompt() + const language = useLanguage() + + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const editor = input.editor() + if (!editor) return + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: uuid(), + filename: file.name, + mime: file.type, + dataUrl, + } + const cursorPosition = prompt.cursor() ?? getCursorPosition(editor) + prompt.set([...prompt.current(), attachment], cursorPosition) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + const current = prompt.current() + const next = current.filter((part) => part.type !== "image" || part.id !== id) + prompt.set(next, prompt.cursor()) + } + + const handlePaste = async (event: ClipboardEvent) => { + if (!input.isFocused()) return + const clipboardData = event.clipboardData + if (!clipboardData) return + + event.preventDefault() + event.stopPropagation() + + const items = Array.from(clipboardData.items) + const fileItems = items.filter((item) => item.kind === "file") + const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + + if (fileItems.length > 0) { + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }) + return + } + + const plainText = clipboardData.getData("text/plain") ?? "" + + // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images + if (input.readClipboardImage && !plainText) { + const file = await input.readClipboardImage() + if (file) { + await addImageAttachment(file) + return + } + } + + if (!plainText) return + + if (largePaste(plainText)) { + if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return + input.focusEditor() + if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return + } + + const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText) + if (inserted) return + + input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) + } + + const handleGlobalDragOver = (event: DragEvent) => { + if (input.isDialogActive()) return + + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + const hasText = event.dataTransfer?.types.includes("text/plain") + if (hasFiles) { + input.setDraggingType("image") + } else if (hasText) { + input.setDraggingType("@mention") + } + } + + const handleGlobalDragLeave = (event: DragEvent) => { + if (input.isDialogActive()) return + if (!event.relatedTarget) { + input.setDraggingType(null) + } + } + + const handleGlobalDrop = async (event: DragEvent) => { + if (input.isDialogActive()) return + + event.preventDefault() + input.setDraggingType(null) + + const plainText = event.dataTransfer?.getData("text/plain") + const filePrefix = "file:" + if (plainText?.startsWith(filePrefix)) { + const filePath = plainText.slice(filePrefix.length) + input.focusEditor() + input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 }) + return + } + + const dropped = event.dataTransfer?.files + if (!dropped) return + + for (const file of Array.from(dropped)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + + onMount(() => { + document.addEventListener("dragover", handleGlobalDragOver) + document.addEventListener("dragleave", handleGlobalDragLeave) + document.addEventListener("drop", handleGlobalDrop) + }) + + onCleanup(() => { + document.removeEventListener("dragover", handleGlobalDragOver) + document.removeEventListener("dragleave", handleGlobalDragLeave) + document.removeEventListener("drop", handleGlobalDrop) + }) + + return { + addImageAttachment, + removeImageAttachment, + handlePaste, + } +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/build-request-parts.test.ts b/Agent-backend/packages/app/src/components/prompt-input/build-request-parts.test.ts new file mode 100644 index 000000000..4c2e2d8be --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, test } from "bun:test" +import type { Prompt } from "@/context/prompt" +import { buildRequestParts } from "./build-request-parts" + +describe("buildRequestParts", () => { + test("builds typed request and optimistic parts without cast path", () => { + const prompt: Prompt = [ + { type: "text", content: "hello", start: 0, end: 5 }, + { + type: "file", + path: "src/foo.ts", + content: "@src/foo.ts", + start: 5, + end: 16, + selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 }, + }, + { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 }, + ] + + const result = buildRequestParts({ + prompt, + context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }], + images: [ + { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" }, + ], + text: "hello @src/foo.ts @planner", + messageID: "msg_1", + sessionID: "ses_1", + sessionDirectory: "/repo", + }) + + expect(result.requestParts[0]?.type).toBe("text") + expect(result.requestParts.some((part) => part.type === "agent")).toBe(true) + expect( + result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")), + ).toBe(true) + expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true) + expect( + result.requestParts.some( + (part) => + part.type === "text" && + part.synthetic && + part.metadata?.opencodeComment && + (part.metadata.opencodeComment as { comment?: string }).comment === "check this", + ), + ).toBe(true) + + expect(result.optimisticParts).toHaveLength(result.requestParts.length) + expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) + }) + + test("deduplicates context files when prompt already includes same path", () => { + const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] + + const result = buildRequestParts({ + prompt, + context: [ + { key: "ctx:dup", type: "file", path: "src/foo.ts" }, + { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" }, + ], + images: [], + text: "@src/foo.ts", + messageID: "msg_2", + sessionID: "ses_2", + sessionDirectory: "/repo", + }) + + const fooFiles = result.requestParts.filter( + (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"), + ) + const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic) + + expect(fooFiles).toHaveLength(2) + expect(synthetic).toHaveLength(1) + }) + + test("handles Windows paths correctly (simulated on macOS)", () => { + const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@src\\foo.ts", + messageID: "msg_win_1", + sessionID: "ses_win_1", + sessionDirectory: "D:\\projects\\myapp", // Windows path + }) + + // Should create valid file URLs + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // URL should be parseable + expect(() => new URL(filePart.url)).not.toThrow() + // Should not have encoded backslashes in wrong place + expect(filePart.url).not.toContain("%5C") + // Should have normalized to forward slashes + expect(filePart.url).toContain("/src/foo.ts") + } + }) + + test("handles Windows absolute path with special characters", () => { + const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@file#name.txt", + messageID: "msg_win_2", + sessionID: "ses_win_2", + sessionDirectory: "C:\\Users\\test\\Documents", // Windows path + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // URL should be parseable + expect(() => new URL(filePart.url)).not.toThrow() + // Special chars should be encoded + expect(filePart.url).toContain("file%23name.txt") + // Should have Windows drive letter properly encoded + expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/) + } + }) + + test("handles Linux absolute paths correctly", () => { + const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@src/app.ts", + messageID: "msg_linux_1", + sessionID: "ses_linux_1", + sessionDirectory: "/home/user/project", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // URL should be parseable + expect(() => new URL(filePart.url)).not.toThrow() + // Should be a normal Unix path + expect(filePart.url).toBe("file:///home/user/project/src/app.ts") + } + }) + + test("handles macOS paths correctly", () => { + const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@README.md", + messageID: "msg_mac_1", + sessionID: "ses_mac_1", + sessionDirectory: "/Users/kelvin/Projects/opencode", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // URL should be parseable + expect(() => new URL(filePart.url)).not.toThrow() + // Should be a normal Unix path + expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md") + } + }) + + test("handles context files with Windows paths", () => { + const prompt: Prompt = [] + + const result = buildRequestParts({ + prompt, + context: [ + { key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" }, + { key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" }, + ], + images: [], + text: "test", + messageID: "msg_win_ctx", + sessionID: "ses_win_ctx", + sessionDirectory: "D:\\workspace\\app", + }) + + const fileParts = result.requestParts.filter((part) => part.type === "file") + expect(fileParts).toHaveLength(2) + + // All file URLs should be valid + fileParts.forEach((part) => { + if (part.type === "file") { + expect(() => new URL(part.url)).not.toThrow() + expect(part.url).not.toContain("%5C") // No encoded backslashes + } + }) + }) + + test("handles absolute Windows paths (user manually specifies full path)", () => { + const prompt: Prompt = [ + { type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 }, + ] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@D:\\other\\project\\file.ts", + messageID: "msg_abs", + sessionID: "ses_abs", + sessionDirectory: "C:\\current\\project", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // Should handle absolute path that differs from sessionDirectory + expect(() => new URL(filePart.url)).not.toThrow() + expect(filePart.url).toContain("/D:/other/project/file.ts") + } + }) + + test("handles selection with query parameters on Windows", () => { + const prompt: Prompt = [ + { + type: "file", + path: "src\\App.tsx", + content: "@src\\App.tsx", + start: 0, + end: 11, + selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 }, + }, + ] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@src\\App.tsx", + messageID: "msg_sel", + sessionID: "ses_sel", + sessionDirectory: "C:\\project", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // Should have query parameters + expect(filePart.url).toContain("?start=10&end=20") + // Should be valid URL + expect(() => new URL(filePart.url)).not.toThrow() + // Query params should parse correctly + const url = new URL(filePart.url) + expect(url.searchParams.get("start")).toBe("10") + expect(url.searchParams.get("end")).toBe("20") + } + }) + + test("handles file paths with dots and special segments on Windows", () => { + const prompt: Prompt = [ + { type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 }, + ] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@..\\..\\shared\\util.ts", + messageID: "msg_dots", + sessionID: "ses_dots", + sessionDirectory: "C:\\projects\\myapp\\src", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // Should be valid URL + expect(() => new URL(filePart.url)).not.toThrow() + // Should preserve .. segments (backend normalizes) + expect(filePart.url).toContain("/..") + } + }) +}) diff --git a/Agent-backend/packages/app/src/components/prompt-input/build-request-parts.ts b/Agent-backend/packages/app/src/components/prompt-input/build-request-parts.ts new file mode 100644 index 000000000..4146fb484 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/build-request-parts.ts @@ -0,0 +1,175 @@ +import { getFilename } from "@opencode-ai/util/path" +import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { FileSelection } from "@/context/file" +import { encodeFilePath } from "@/context/file/path" +import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" +import { Identifier } from "@/utils/id" +import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note" + +type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } + +type ContextFile = { + key: string + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +type BuildRequestPartsInput = { + prompt: Prompt + context: ContextFile[] + images: ImageAttachmentPart[] + text: string + messageID: string + sessionID: string + sessionDirectory: string +} + +const absolute = (directory: string, path: string) => { + if (path.startsWith("/")) return path + if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path + if (path.startsWith("\\\\") || path.startsWith("//")) return path + return `${directory.replace(/[\\/]+$/, "")}/${path}` +} + +const fileQuery = (selection: FileSelection | undefined) => + selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" + +const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" +const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" + +const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => { + if (part.type === "text") { + return { + id: part.id, + type: "text", + text: part.text, + synthetic: part.synthetic, + ignored: part.ignored, + time: part.time, + metadata: part.metadata, + sessionID, + messageID, + } + } + if (part.type === "file") { + return { + id: part.id, + type: "file", + mime: part.mime, + filename: part.filename, + url: part.url, + source: part.source, + sessionID, + messageID, + } + } + return { + id: part.id, + type: "agent", + name: part.name, + source: part.source, + sessionID, + messageID, + } +} + +export function buildRequestParts(input: BuildRequestPartsInput) { + const requestParts: PromptRequestPart[] = [ + { + id: Identifier.ascending("part"), + type: "text", + text: input.text, + }, + ] + + const files = input.prompt.filter(isFileAttachment).map((attachment) => { + const path = absolute(input.sessionDirectory, attachment.path) + return { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, + filename: getFilename(attachment.path), + source: { + type: "file", + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path, + }, + } satisfies PromptRequestPart + }) + + const agents = input.prompt.filter(isAgentAttachment).map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "agent", + name: attachment.name, + source: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + } satisfies PromptRequestPart + }) + + const used = new Set(files.map((part) => part.url)) + const context = input.context.flatMap((item) => { + const path = absolute(input.sessionDirectory, item.path) + const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}` + const comment = item.comment?.trim() + if (!comment && used.has(url)) return [] + used.add(url) + + const filePart = { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url, + filename: getFilename(item.path), + } satisfies PromptRequestPart + + if (!comment) return [filePart] + + return [ + { + id: Identifier.ascending("part"), + type: "text", + text: formatCommentNote({ path: item.path, selection: item.selection, comment }), + synthetic: true, + metadata: createCommentMetadata({ + path: item.path, + selection: item.selection, + comment, + preview: item.preview, + origin: item.commentOrigin, + }), + } satisfies PromptRequestPart, + filePart, + ] + }) + + const images = input.images.map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "file", + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + } satisfies PromptRequestPart + }) + + requestParts.push(...files, ...context, ...agents, ...images) + + return { + requestParts, + optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)), + } +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/context-items.tsx b/Agent-backend/packages/app/src/components/prompt-input/context-items.tsx new file mode 100644 index 000000000..b138fe3ef --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/context-items.tsx @@ -0,0 +1,88 @@ +import { Component, For, Show } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import type { ContextItem } from "@/context/prompt" + +type PromptContextItem = ContextItem & { key: string } + +type ContextItemsProps = { + items: PromptContextItem[] + active: (item: PromptContextItem) => boolean + openComment: (item: PromptContextItem) => void + remove: (item: PromptContextItem) => void + t: (key: string) => string +} + +export const PromptContextItems: Component = (props) => { + return ( + 0}> +
+ + {(item) => { + const directory = getDirectory(item.path) + const filename = getFilename(item.path) + const label = getFilenameTruncated(item.path, 14) + const selected = props.active(item) + + return ( + + + {directory} + + {filename} + + } + placement="top" + openDelay={2000} + > +
props.openComment(item)} + > +
+ +
+ {label} + + {(sel) => ( + + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + + )} + +
+ { + e.stopPropagation() + props.remove(item) + }} + aria-label={props.t("prompt.context.removeFile")} + /> +
+ + {(comment) =>
{comment()}
} +
+
+
+ ) + }} +
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/drag-overlay.tsx b/Agent-backend/packages/app/src/components/prompt-input/drag-overlay.tsx new file mode 100644 index 000000000..41962ce53 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -0,0 +1,25 @@ +import { Component, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" + +type PromptDragOverlayProps = { + type: "image" | "@mention" | null + label: string +} + +const kindToIcon = { + image: "photo", + "@mention": "link", +} as const + +export const PromptDragOverlay: Component = (props) => { + return ( + +
+
+ + {props.label} +
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/editor-dom.test.ts b/Agent-backend/packages/app/src/components/prompt-input/editor-dom.test.ts new file mode 100644 index 000000000..3088522a5 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/editor-dom.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom" + +describe("prompt-input editor dom", () => { + test("createTextFragment preserves newlines with consecutive br nodes", () => { + const fragment = createTextFragment("foo\n\nbar") + const container = document.createElement("div") + container.appendChild(fragment) + + expect(container.childNodes.length).toBe(4) + expect(container.childNodes[0]?.textContent).toBe("foo") + expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") + expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR") + expect(container.childNodes[3]?.textContent).toBe("bar") + }) + + test("createTextFragment keeps trailing newline as terminal break", () => { + const fragment = createTextFragment("foo\n") + const container = document.createElement("div") + container.appendChild(fragment) + + expect(container.childNodes.length).toBe(2) + expect(container.childNodes[0]?.textContent).toBe("foo") + expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") + }) + + test("createTextFragment avoids break-node explosion for large multiline content", () => { + const content = Array.from({ length: 220 }, () => "line").join("\n") + const fragment = createTextFragment(content) + const container = document.createElement("div") + container.appendChild(fragment) + + expect(container.childNodes.length).toBe(1) + expect(container.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE) + expect(container.textContent).toBe(content) + }) + + test("createTextFragment keeps terminal break in large multiline fallback", () => { + const content = `${Array.from({ length: 220 }, () => "line").join("\n")}\n` + const fragment = createTextFragment(content) + const container = document.createElement("div") + container.appendChild(fragment) + + expect(container.childNodes.length).toBe(2) + expect(container.childNodes[0]?.textContent).toBe(content.slice(0, -1)) + expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") + }) + + test("length helpers treat breaks as one char and ignore zero-width chars", () => { + const container = document.createElement("div") + container.appendChild(document.createTextNode("ab\u200B")) + container.appendChild(document.createElement("br")) + container.appendChild(document.createTextNode("cd")) + + expect(getNodeLength(container.childNodes[0]!)).toBe(2) + expect(getNodeLength(container.childNodes[1]!)).toBe(1) + expect(getTextLength(container)).toBe(5) + }) + + test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => { + const container = document.createElement("div") + const pill = document.createElement("span") + pill.dataset.type = "file" + pill.textContent = "@file" + container.appendChild(document.createTextNode("ab")) + container.appendChild(pill) + container.appendChild(document.createElement("br")) + container.appendChild(document.createTextNode("cd")) + document.body.appendChild(container) + + setCursorPosition(container, 2) + expect(getCursorPosition(container)).toBe(2) + + setCursorPosition(container, 7) + expect(getCursorPosition(container)).toBe(7) + + setCursorPosition(container, 8) + expect(getCursorPosition(container)).toBe(8) + + container.remove() + }) + + test("setCursorPosition and getCursorPosition round-trip across blank lines", () => { + const container = document.createElement("div") + container.appendChild(document.createTextNode("a")) + container.appendChild(document.createElement("br")) + container.appendChild(document.createElement("br")) + container.appendChild(document.createTextNode("b")) + document.body.appendChild(container) + + setCursorPosition(container, 2) + expect(getCursorPosition(container)).toBe(2) + + setCursorPosition(container, 3) + expect(getCursorPosition(container)).toBe(3) + + container.remove() + }) +}) diff --git a/Agent-backend/packages/app/src/components/prompt-input/editor-dom.ts b/Agent-backend/packages/app/src/components/prompt-input/editor-dom.ts new file mode 100644 index 000000000..8575140d7 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/editor-dom.ts @@ -0,0 +1,148 @@ +const MAX_BREAKS = 200 + +export function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + let breaks = 0 + for (const char of content) { + if (char !== "\n") continue + breaks += 1 + if (breaks > MAX_BREAKS) { + const tail = content.endsWith("\n") + const text = tail ? content.slice(0, -1) : content + if (text) fragment.appendChild(document.createTextNode(text)) + if (tail) fragment.appendChild(document.createElement("br")) + return fragment + } + } + + const segments = content.split("\n") + segments.forEach((segment, index) => { + if (segment) { + fragment.appendChild(document.createTextNode(segment)) + } + if (index < segments.length - 1) { + fragment.appendChild(document.createElement("br")) + } + }) + return fragment +} + +export function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +export function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let length = 0 + for (const child of Array.from(node.childNodes)) { + length += getTextLength(child) + } + return length +} + +export function getCursorPosition(parent: HTMLElement): number { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 + const preCaretRange = range.cloneRange() + preCaretRange.selectNodeContents(parent) + preCaretRange.setEnd(range.startContainer, range.startOffset) + return getTextLength(preCaretRange.cloneContents()) +} + +export function setCursorPosition(parent: HTMLElement, position: number) { + let remaining = position + let node = parent.firstChild + while (node) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + range.setStart(node, remaining) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + + if ((isPill || isBreak) && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + if (remaining === 0) { + range.setStartBefore(node) + } + if (remaining > 0 && isPill) { + range.setStartAfter(node) + } + if (remaining > 0 && isBreak) { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) { + range.setStart(next, 0) + } + if (!next || next.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(node) + } + } + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + + remaining -= length + node = node.nextSibling + } + + const fallbackRange = document.createRange() + const fallbackSelection = window.getSelection() + const last = parent.lastChild + if (last && last.nodeType === Node.TEXT_NODE) { + const len = last.textContent ? last.textContent.length : 0 + fallbackRange.setStart(last, len) + } + if (!last || last.nodeType !== Node.TEXT_NODE) { + fallbackRange.selectNodeContents(parent) + } + fallbackRange.collapse(false) + fallbackSelection?.removeAllRanges() + fallbackSelection?.addRange(fallbackRange) +} + +export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) { + let remaining = offset + const nodes = Array.from(parent.childNodes) + + for (const node of nodes) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + if (edge === "start") range.setStart(node, remaining) + if (edge === "end") range.setEnd(node, remaining) + return + } + + if ((isPill || isBreak) && remaining <= length) { + if (edge === "start" && remaining === 0) range.setStartBefore(node) + if (edge === "start" && remaining > 0) range.setStartAfter(node) + if (edge === "end" && remaining === 0) range.setEndBefore(node) + if (edge === "end" && remaining > 0) range.setEndAfter(node) + return + } + + remaining -= length + } +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/history.test.ts b/Agent-backend/packages/app/src/components/prompt-input/history.test.ts new file mode 100644 index 000000000..37b5ce196 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/history.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test" +import type { Prompt } from "@/context/prompt" +import { + canNavigateHistoryAtCursor, + clonePromptParts, + normalizePromptHistoryEntry, + navigatePromptHistory, + prependHistoryEntry, + promptLength, + type PromptHistoryComment, +} from "./history" + +const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }] +const comment = (id: string, value = "note"): PromptHistoryComment => ({ + id, + path: "src/a.ts", + selection: { start: 2, end: 4 }, + comment: value, + time: 1, + origin: "review", + preview: "const a = 1", +}) + +describe("prompt-input history", () => { + test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => { + const first = prependHistoryEntry([], DEFAULT_PROMPT) + expect(first).toEqual([]) + + const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")]) + expect(commentsOnly).toHaveLength(1) + + const withOne = prependHistoryEntry([], text("hello")) + expect(withOne).toHaveLength(1) + + const deduped = prependHistoryEntry(withOne, text("hello")) + expect(deduped).toBe(withOne) + + const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")]) + expect(dedupedComments).toBe(commentsOnly) + }) + + test("navigatePromptHistory restores saved prompt when moving down from newest", () => { + const entries = [text("third"), text("second"), text("first")] + const up = navigatePromptHistory({ + direction: "up", + entries, + historyIndex: -1, + currentPrompt: text("draft"), + currentComments: [comment("draft")], + savedPrompt: null, + }) + expect(up.handled).toBe(true) + if (!up.handled) throw new Error("expected handled") + expect(up.historyIndex).toBe(0) + expect(up.cursor).toBe("start") + expect(up.entry.comments).toEqual([]) + + const down = navigatePromptHistory({ + direction: "down", + entries, + historyIndex: up.historyIndex, + currentPrompt: text("ignored"), + currentComments: [], + savedPrompt: up.savedPrompt, + }) + expect(down.handled).toBe(true) + if (!down.handled) throw new Error("expected handled") + expect(down.historyIndex).toBe(-1) + expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft") + expect(down.entry.comments).toEqual([comment("draft")]) + }) + + test("navigatePromptHistory keeps entry comments when moving through history", () => { + const entries = [ + { + prompt: text("with comment"), + comments: [comment("c1")], + }, + ] + + const up = navigatePromptHistory({ + direction: "up", + entries, + historyIndex: -1, + currentPrompt: text("draft"), + currentComments: [], + savedPrompt: null, + }) + + expect(up.handled).toBe(true) + if (!up.handled) throw new Error("expected handled") + expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment") + expect(up.entry.comments).toEqual([comment("c1")]) + }) + + test("normalizePromptHistoryEntry supports legacy prompt arrays", () => { + const entry = normalizePromptHistoryEntry(text("legacy")) + expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy") + expect(entry.comments).toEqual([]) + }) + + test("helpers clone prompt and count text content length", () => { + const original: Prompt = [ + { type: "text", content: "one", start: 0, end: 3 }, + { + type: "file", + path: "src/a.ts", + content: "@src/a.ts", + start: 3, + end: 12, + selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 }, + }, + { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" }, + ] + const copy = clonePromptParts(original) + expect(copy).not.toBe(original) + expect(promptLength(copy)).toBe(12) + if (copy[1]?.type !== "file") throw new Error("expected file") + copy[1].selection!.startLine = 9 + if (original[1]?.type !== "file") throw new Error("expected file") + expect(original[1].selection?.startLine).toBe(1) + }) + + test("canNavigateHistoryAtCursor only allows prompt boundaries", () => { + const value = "a\nb\nc" + + expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true) + expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false) + + expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false) + expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false) + + expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false) + expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true) + + expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true) + expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true) + expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false) + expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false) + + expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true) + expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true) + expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true) + expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true) + expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false) + expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false) + }) +}) diff --git a/Agent-backend/packages/app/src/components/prompt-input/history.ts b/Agent-backend/packages/app/src/components/prompt-input/history.ts new file mode 100644 index 000000000..de6265321 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/history.ts @@ -0,0 +1,256 @@ +import type { Prompt } from "@/context/prompt" +import type { SelectedLineRange } from "@/context/file" + +const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export const MAX_HISTORY = 100 + +export type PromptHistoryComment = { + id: string + path: string + selection: SelectedLineRange + comment: string + time: number + origin?: "review" | "file" + preview?: string +} + +export type PromptHistoryEntry = { + prompt: Prompt + comments: PromptHistoryComment[] +} + +export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry + +export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) { + const position = Math.max(0, Math.min(cursor, text.length)) + const atStart = position === 0 + const atEnd = position === text.length + if (inHistory) return atStart || atEnd + if (direction === "up") return position === 0 + return position === text.length +} + +export function clonePromptParts(prompt: Prompt): Prompt { + return prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) +} + +function cloneSelection(selection: SelectedLineRange): SelectedLineRange { + return { + start: selection.start, + end: selection.end, + ...(selection.side ? { side: selection.side } : {}), + ...(selection.endSide ? { endSide: selection.endSide } : {}), + } +} + +export function clonePromptHistoryComments(comments: PromptHistoryComment[]) { + return comments.map((comment) => ({ + ...comment, + selection: cloneSelection(comment.selection), + })) +} + +export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry { + if (Array.isArray(entry)) { + return { + prompt: clonePromptParts(entry), + comments: [], + } + } + return { + prompt: clonePromptParts(entry.prompt), + comments: clonePromptHistoryComments(entry.comments), + } +} + +export function promptLength(prompt: Prompt) { + return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) +} + +export function prependHistoryEntry( + entries: PromptHistoryStoredEntry[], + prompt: Prompt, + comments: PromptHistoryComment[] = [], + max = MAX_HISTORY, +) { + const text = prompt + .map((part) => ("content" in part ? part.content : "")) + .join("") + .trim() + const hasImages = prompt.some((part) => part.type === "image") + const hasComments = comments.some((comment) => !!comment.comment.trim()) + if (!text && !hasImages && !hasComments) return entries + + const entry = { + prompt: clonePromptParts(prompt), + comments: clonePromptHistoryComments(comments), + } satisfies PromptHistoryEntry + const last = entries[0] + if (last && isPromptEqual(last, entry)) return entries + return [entry, ...entries].slice(0, max) +} + +function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) { + return ( + commentA.path === commentB.path && + commentA.comment === commentB.comment && + commentA.origin === commentB.origin && + commentA.preview === commentB.preview && + commentA.selection.start === commentB.selection.start && + commentA.selection.end === commentB.selection.end && + commentA.selection.side === commentB.selection.side && + commentA.selection.endSide === commentB.selection.endSide + ) +} + +function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) { + const entryA = normalizePromptHistoryEntry(promptA) + const entryB = normalizePromptHistoryEntry(promptB) + if (entryA.prompt.length !== entryB.prompt.length) return false + for (let i = 0; i < entryA.prompt.length; i++) { + const partA = entryA.prompt[i] + const partB = entryB.prompt[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false + if (partA.type === "file") { + if (partA.path !== (partB.type === "file" ? partB.path : "")) return false + const a = partA.selection + const b = partB.type === "file" ? partB.selection : undefined + const sameSelection = + (!a && !b) || + (!!a && + !!b && + a.startLine === b.startLine && + a.startChar === b.startChar && + a.endLine === b.endLine && + a.endChar === b.endChar) + if (!sameSelection) return false + } + if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false + if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false + } + if (entryA.comments.length !== entryB.comments.length) return false + for (let i = 0; i < entryA.comments.length; i++) { + const commentA = entryA.comments[i] + const commentB = entryB.comments[i] + if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false + } + return true +} + +type HistoryNavInput = { + direction: "up" | "down" + entries: PromptHistoryStoredEntry[] + historyIndex: number + currentPrompt: Prompt + currentComments: PromptHistoryComment[] + savedPrompt: PromptHistoryEntry | null +} + +type HistoryNavResult = + | { + handled: false + historyIndex: number + savedPrompt: PromptHistoryEntry | null + } + | { + handled: true + historyIndex: number + savedPrompt: PromptHistoryEntry | null + entry: PromptHistoryEntry + cursor: "start" | "end" + } + +export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult { + if (input.direction === "up") { + if (input.entries.length === 0) { + return { + handled: false, + historyIndex: input.historyIndex, + savedPrompt: input.savedPrompt, + } + } + + if (input.historyIndex === -1) { + const entry = normalizePromptHistoryEntry(input.entries[0]) + return { + handled: true, + historyIndex: 0, + savedPrompt: { + prompt: clonePromptParts(input.currentPrompt), + comments: clonePromptHistoryComments(input.currentComments), + }, + entry, + cursor: "start", + } + } + + if (input.historyIndex < input.entries.length - 1) { + const next = input.historyIndex + 1 + const entry = normalizePromptHistoryEntry(input.entries[next]) + return { + handled: true, + historyIndex: next, + savedPrompt: input.savedPrompt, + entry, + cursor: "start", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + savedPrompt: input.savedPrompt, + } + } + + if (input.historyIndex > 0) { + const next = input.historyIndex - 1 + const entry = normalizePromptHistoryEntry(input.entries[next]) + return { + handled: true, + historyIndex: next, + savedPrompt: input.savedPrompt, + entry, + cursor: "end", + } + } + + if (input.historyIndex === 0) { + if (input.savedPrompt) { + return { + handled: true, + historyIndex: -1, + savedPrompt: null, + entry: input.savedPrompt, + cursor: "end", + } + } + + return { + handled: true, + historyIndex: -1, + savedPrompt: null, + entry: { + prompt: DEFAULT_PROMPT, + comments: [], + }, + cursor: "end", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + savedPrompt: input.savedPrompt, + } +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/image-attachments.tsx b/Agent-backend/packages/app/src/components/prompt-input/image-attachments.tsx new file mode 100644 index 000000000..835fddc30 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/image-attachments.tsx @@ -0,0 +1,58 @@ +import { Component, For, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import type { ImageAttachmentPart } from "@/context/prompt" + +type PromptImageAttachmentsProps = { + attachments: ImageAttachmentPart[] + onOpen: (attachment: ImageAttachmentPart) => void + onRemove: (id: string) => void + removeLabel: string +} + +const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" +const imageClass = + "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" +const removeClass = + "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" +const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" + +export const PromptImageAttachments: Component = (props) => { + return ( + 0}> +
+ + {(attachment) => ( +
+ + +
+ } + > + {attachment.filename} props.onOpen(attachment)} + /> + + +
+ {attachment.filename} +
+
+ )} + +
+ + ) +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/placeholder.test.ts b/Agent-backend/packages/app/src/components/prompt-input/placeholder.test.ts new file mode 100644 index 000000000..5f6aa59e9 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/placeholder.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { promptPlaceholder } from "./placeholder" + +describe("promptPlaceholder", () => { + const t = (key: string, params?: Record) => `${key}${params?.example ? `:${params.example}` : ""}` + + test("returns shell placeholder in shell mode", () => { + const value = promptPlaceholder({ + mode: "shell", + commentCount: 0, + example: "example", + suggest: true, + t, + }) + expect(value).toBe("prompt.placeholder.shell") + }) + + test("returns summarize placeholders for comment context", () => { + expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe( + "prompt.placeholder.summarizeComment", + ) + expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe( + "prompt.placeholder.summarizeComments", + ) + }) + + test("returns default placeholder with example when suggestions enabled", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + suggest: true, + t, + }) + expect(value).toBe("prompt.placeholder.normal:translated-example") + }) + + test("returns simple placeholder when suggestions disabled", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + suggest: false, + t, + }) + expect(value).toBe("prompt.placeholder.simple") + }) +}) diff --git a/Agent-backend/packages/app/src/components/prompt-input/placeholder.ts b/Agent-backend/packages/app/src/components/prompt-input/placeholder.ts new file mode 100644 index 000000000..395fee51b --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/placeholder.ts @@ -0,0 +1,15 @@ +type PromptPlaceholderInput = { + mode: "normal" | "shell" + commentCount: number + example: string + suggest: boolean + t: (key: string, params?: Record) => string +} + +export function promptPlaceholder(input: PromptPlaceholderInput) { + if (input.mode === "shell") return input.t("prompt.placeholder.shell") + if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") + if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + if (!input.suggest) return input.t("prompt.placeholder.simple") + return input.t("prompt.placeholder.normal", { example: input.example }) +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/slash-popover.tsx b/Agent-backend/packages/app/src/components/prompt-input/slash-popover.tsx new file mode 100644 index 000000000..65eb01c79 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/slash-popover.tsx @@ -0,0 +1,141 @@ +import { Component, For, Match, Show, Switch } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" + +export type AtOption = + | { type: "agent"; name: string; display: string } + | { type: "file"; path: string; display: string; recent?: boolean } + +export interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string + type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" +} + +type PromptPopoverProps = { + popover: "at" | "slash" | null + setSlashPopoverRef: (el: HTMLDivElement) => void + atFlat: AtOption[] + atActive?: string + atKey: (item: AtOption) => string + setAtActive: (id: string) => void + onAtSelect: (item: AtOption) => void + slashFlat: SlashCommand[] + slashActive?: string + setSlashActive: (id: string) => void + onSlashSelect: (item: SlashCommand) => void + commandKeybind: (id: string) => string | undefined + t: (key: string) => string +} + +export const PromptPopover: Component = (props) => { + return ( + +
{ + if (props.popover === "slash") props.setSlashPopoverRef(el) + }} + class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] + bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" + onMouseDown={(e) => e.preventDefault()} + > + + + 0} + fallback={
{props.t("prompt.popover.emptyResults")}
} + > + + {(item) => { + const key = props.atKey(item) + + if (item.type === "agent") { + return ( + + ) + } + + const isDirectory = item.path.endsWith("/") + const directory = isDirectory ? item.path : getDirectory(item.path) + const filename = isDirectory ? "" : getFilename(item.path) + + return ( + + ) + }} + +
+
+ + 0} + fallback={
{props.t("prompt.popover.emptyCommands")}
} + > + + {(cmd) => ( + + )} + +
+
+
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/prompt-input/submit.test.ts b/Agent-backend/packages/app/src/components/prompt-input/submit.test.ts new file mode 100644 index 000000000..4109417d2 --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/submit.test.ts @@ -0,0 +1,274 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" +import type { Prompt } from "@/context/prompt" + +let createPromptSubmit: typeof import("./submit").createPromptSubmit + +const createdClients: string[] = [] +const createdSessions: string[] = [] +const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] +const optimistic: Array<{ + message: { + agent: string + model: { providerID: string; modelID: string } + variant?: string + } +}> = [] +const sentShell: string[] = [] +const syncedDirectories: string[] = [] + +let params: { id?: string } = {} +let selected = "/repo/worktree-a" +let variant: string | undefined + +const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] + +const clientFor = (directory: string) => { + createdClients.push(directory) + return { + session: { + create: async () => { + createdSessions.push(directory) + return { data: { id: `session-${createdSessions.length}` } } + }, + shell: async () => { + sentShell.push(directory) + return { data: undefined } + }, + prompt: async () => ({ data: undefined }), + promptAsync: async () => ({ data: undefined }), + command: async () => ({ data: undefined }), + abort: async () => ({ data: undefined }), + }, + worktree: { + create: async () => ({ data: { directory: `${directory}/new` } }), + }, + } +} + +beforeAll(async () => { + const rootClient = clientFor("/repo/main") + + mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, + useParams: () => params, + })) + + mock.module("@opencode-ai/sdk/v2/client", () => ({ + createOpencodeClient: (input: { directory: string }) => { + createdClients.push(input.directory) + return clientFor(input.directory) + }, + })) + + mock.module("@opencode-ai/ui/toast", () => ({ + showToast: () => 0, + })) + + mock.module("@opencode-ai/util/encode", () => ({ + base64Encode: (value: string) => value, + })) + + mock.module("@/context/local", () => ({ + useLocal: () => ({ + model: { + current: () => ({ id: "model", provider: { id: "provider" } }), + variant: { current: () => variant }, + }, + agent: { + current: () => ({ name: "agent" }), + }, + }), + })) + + mock.module("@/context/permission", () => ({ + usePermission: () => ({ + enableAutoAccept(sessionID: string, directory: string) { + enabledAutoAccept.push({ sessionID, directory }) + }, + }), + })) + + mock.module("@/context/prompt", () => ({ + usePrompt: () => ({ + current: () => promptValue, + reset: () => undefined, + set: () => undefined, + context: { + add: () => undefined, + remove: () => undefined, + items: () => [], + }, + }), + })) + + mock.module("@/context/layout", () => ({ + useLayout: () => ({ + handoff: { + setTabs: () => undefined, + }, + }), + })) + + mock.module("@/context/sdk", () => ({ + useSDK: () => { + const sdk = { + directory: "/repo/main", + client: rootClient, + url: "http://localhost:4096", + createClient(opts: any) { + return clientFor(opts.directory) + }, + } + return sdk + }, + })) + + mock.module("@/context/sync", () => ({ + useSync: () => ({ + data: { command: [] }, + session: { + optimistic: { + add: (value: { + message: { agent: string; model: { providerID: string; modelID: string }; variant?: string } + }) => { + optimistic.push(value) + }, + remove: () => undefined, + }, + }, + set: () => undefined, + }), + })) + + mock.module("@/context/global-sync", () => ({ + useGlobalSync: () => ({ + child: (directory: string) => { + syncedDirectories.push(directory) + return [{}, () => undefined] + }, + }), + })) + + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ + fetch: fetch, + }), + })) + + mock.module("@/context/language", () => ({ + useLanguage: () => ({ + t: (key: string) => key, + }), + })) + + const mod = await import("./submit") + createPromptSubmit = mod.createPromptSubmit +}) + +beforeEach(() => { + createdClients.length = 0 + createdSessions.length = 0 + enabledAutoAccept.length = 0 + optimistic.length = 0 + params = {} + sentShell.length = 0 + syncedDirectories.length = 0 + selected = "/repo/worktree-a" + variant = undefined +}) + +describe("prompt submit worktree selection", () => { + test("reads the latest worktree accessor value per submit", async () => { + const submit = createPromptSubmit({ + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "shell", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + newSessionWorktree: () => selected, + onNewSessionWorktreeReset: () => undefined, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + + await submit.handleSubmit(event) + selected = "/repo/worktree-b" + await submit.handleSubmit(event) + + expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + }) + + test("applies auto-accept to newly created sessions", async () => { + const submit = createPromptSubmit({ + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => true, + mode: () => "shell", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + newSessionWorktree: () => selected, + onNewSessionWorktreeReset: () => undefined, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + + await submit.handleSubmit(event) + + expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) + }) + + test("includes the selected variant on optimistic prompts", async () => { + params = { id: "session-1" } + variant = "high" + + const submit = createPromptSubmit({ + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + + await submit.handleSubmit(event) + + expect(optimistic).toHaveLength(1) + expect(optimistic[0]).toMatchObject({ + message: { + agent: "agent", + model: { providerID: "provider", modelID: "model" }, + variant: "high", + }, + }) + }) +}) diff --git a/Agent-backend/packages/app/src/components/prompt-input/submit.ts b/Agent-backend/packages/app/src/components/prompt-input/submit.ts new file mode 100644 index 000000000..fee6b070d --- /dev/null +++ b/Agent-backend/packages/app/src/components/prompt-input/submit.ts @@ -0,0 +1,428 @@ +import type { Message } from "@opencode-ai/sdk/v2/client" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" +import { useNavigate, useParams } from "@solidjs/router" +import type { Accessor } from "solid-js" +import type { FileSelection } from "@/context/file" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" +import { usePermission } from "@/context/permission" +import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { Identifier } from "@/utils/id" +import { Worktree as WorktreeState } from "@/utils/worktree" +import { buildRequestParts } from "./build-request-parts" +import { setCursorPosition } from "./editor-dom" +import { formatServerError } from "@/utils/server-errors" + +type PendingPrompt = { + abort: AbortController + cleanup: VoidFunction +} + +const pending = new Map() + +type PromptSubmitInput = { + info: Accessor<{ id: string } | undefined> + imageAttachments: Accessor + commentCount: Accessor + autoAccept: Accessor + mode: Accessor<"normal" | "shell"> + working: Accessor + editor: () => HTMLDivElement | undefined + queueScroll: () => void + promptLength: (prompt: Prompt) => number + addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void + resetHistoryNavigation: () => void + setMode: (mode: "normal" | "shell") => void + setPopover: (popover: "at" | "slash" | null) => void + newSessionWorktree?: Accessor + onNewSessionWorktreeReset?: () => void + onSubmit?: () => void +} + +type CommentItem = { + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export function createPromptSubmit(input: PromptSubmitInput) { + const navigate = useNavigate() + const sdk = useSDK() + const sync = useSync() + const globalSync = useGlobalSync() + const local = useLocal() + const permission = usePermission() + const prompt = usePrompt() + const layout = useLayout() + const language = useLanguage() + const params = useParams() + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + const abort = async () => { + const sessionID = params.id + if (!sessionID) return Promise.resolve() + + globalSync.todo.set(sessionID, []) + const [, setStore] = globalSync.child(sdk.directory) + setStore("todo", sessionID, []) + + const queued = pending.get(sessionID) + if (queued) { + queued.abort.abort() + queued.cleanup() + pending.delete(sessionID) + return Promise.resolve() + } + return sdk.client.session + .abort({ + sessionID, + }) + .catch(() => {}) + } + + const restoreCommentItems = (items: CommentItem[]) => { + for (const item of items) { + prompt.context.add({ + type: "file", + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }) + } + } + + const removeCommentItems = (items: { key: string }[]) => { + for (const item of items) { + prompt.context.remove(item.key) + } + } + + const handleSubmit = async (event: Event) => { + event.preventDefault() + + const currentPrompt = prompt.current() + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const images = input.imageAttachments().slice() + const mode = input.mode() + + if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) { + if (input.working()) abort() + return + } + + const currentModel = local.model.current() + const currentAgent = local.agent.current() + if (!currentModel || !currentAgent) { + showToast({ + title: language.t("prompt.toast.modelAgentRequired.title"), + description: language.t("prompt.toast.modelAgentRequired.description"), + }) + return + } + + input.addToHistory(currentPrompt, mode) + input.resetHistoryNavigation() + + const projectDirectory = sdk.directory + const isNewSession = !params.id + const shouldAutoAccept = isNewSession && input.autoAccept() + const worktreeSelection = input.newSessionWorktree?.() || "main" + + let sessionDirectory = projectDirectory + let client = sdk.client + + if (isNewSession) { + if (worktreeSelection === "create") { + const createdWorktree = await client.worktree + .create({ directory: projectDirectory }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: errorMessage(err), + }) + return undefined + }) + + if (!createdWorktree?.directory) { + showToast({ + title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: language.t("common.requestFailed"), + }) + return + } + WorktreeState.pending(createdWorktree.directory) + sessionDirectory = createdWorktree.directory + } + + if (worktreeSelection !== "main" && worktreeSelection !== "create") { + sessionDirectory = worktreeSelection + } + + if (sessionDirectory !== projectDirectory) { + client = sdk.createClient({ + directory: sessionDirectory, + throwOnError: true, + }) + globalSync.child(sessionDirectory) + } + + input.onNewSessionWorktreeReset?.() + } + + let session = input.info() + if (!session && isNewSession) { + session = await client.session + .create() + .then((x) => x.data ?? undefined) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.sessionCreateFailed.title"), + description: errorMessage(err), + }) + return undefined + }) + if (session) { + if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + } + } + if (!session) { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: language.t("prompt.toast.promptSendFailed.description"), + }) + return + } + + input.onSubmit?.() + + const model = { + modelID: currentModel.id, + providerID: currentModel.provider.id, + } + const agent = currentAgent.name + const variant = local.model.variant.current() + + const clearInput = () => { + prompt.reset() + input.setMode("normal") + input.setPopover(null) + } + + const restoreInput = () => { + prompt.set(currentPrompt, input.promptLength(currentPrompt)) + input.setMode(mode) + input.setPopover(null) + requestAnimationFrame(() => { + const editor = input.editor() + if (!editor) return + editor.focus() + setCursorPosition(editor, input.promptLength(currentPrompt)) + input.queueScroll() + }) + } + + if (mode === "shell") { + clearInput() + client.session + .shell({ + sessionID: session.id, + agent, + model, + command: text, + }) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.shellSendFailed.title"), + description: errorMessage(err), + }) + restoreInput() + }) + return + } + + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) + const customCommand = sync.data.command.find((c) => c.name === commandName) + if (customCommand) { + clearInput() + client.session + .command({ + sessionID: session.id, + command: commandName, + arguments: args.join(" "), + agent, + model: `${model.providerID}/${model.modelID}`, + variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), + }) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.commandSendFailed.title"), + description: formatServerError(err, language.t, language.t("common.requestFailed")), + }) + restoreInput() + }) + return + } + } + + const context = prompt.context.items().slice() + const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) + + const messageID = Identifier.ascending("message") + const { requestParts, optimisticParts } = buildRequestParts({ + prompt: currentPrompt, + context, + images, + text, + sessionID: session.id, + messageID, + sessionDirectory, + }) + + const optimisticMessage: Message = { + id: messageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent, + model, + variant, + } + + const addOptimisticMessage = () => + sync.session.optimistic.add({ + directory: sessionDirectory, + sessionID: session.id, + message: optimisticMessage, + parts: optimisticParts, + }) + + const removeOptimisticMessage = () => + sync.session.optimistic.remove({ + directory: sessionDirectory, + sessionID: session.id, + messageID, + }) + + removeCommentItems(commentItems) + clearInput() + addOptimisticMessage() + + const waitForWorktree = async () => { + const worktree = WorktreeState.get(sessionDirectory) + if (!worktree || worktree.status !== "pending") return true + + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) + } + + const controller = new AbortController() + const cleanup = () => { + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) + } + removeOptimisticMessage() + restoreCommentItems(commentItems) + restoreInput() + } + + pending.set(session.id, { abort: controller, cleanup }) + + const abortWait = new Promise>>((resolve) => { + if (controller.signal.aborted) { + resolve({ status: "failed", message: "aborted" }) + return + } + controller.signal.addEventListener( + "abort", + () => { + resolve({ status: "failed", message: "aborted" }) + }, + { once: true }, + ) + }) + + const timeoutMs = 5 * 60 * 1000 + const timer = { id: undefined as number | undefined } + const timeout = new Promise>>((resolve) => { + timer.id = window.setTimeout(() => { + resolve({ + status: "failed", + message: language.t("workspace.error.stillPreparing"), + }) + }, timeoutMs) + }) + + const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => { + if (timer.id === undefined) return + clearTimeout(timer.id) + }) + pending.delete(session.id) + if (controller.signal.aborted) return false + if (result.status === "failed") throw new Error(result.message) + return true + } + + const send = async () => { + const ok = await waitForWorktree() + if (!ok) return + await client.session.promptAsync({ + sessionID: session.id, + agent, + model, + messageID, + parts: requestParts, + variant, + }) + } + + void send().catch((err) => { + pending.delete(session.id) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) + } + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + removeOptimisticMessage() + restoreCommentItems(commentItems) + restoreInput() + }) + } + + return { + abort, + handleSubmit, + } +} diff --git a/Agent-backend/packages/app/src/components/server/CLAUDE.md b/Agent-backend/packages/app/src/components/server/CLAUDE.md new file mode 100644 index 000000000..a3613468a --- /dev/null +++ b/Agent-backend/packages/app/src/components/server/CLAUDE.md @@ -0,0 +1,16 @@ +# server + +> **Path:** `Agent-backend/packages/app/src/components/server/` + +See parent CLAUDE.md at `packages/app/CLAUDE.md` for detailed documentation of this directory. + +## Purpose + +Server-related UI components. Contains a single component for rendering server connection rows in the server selection dialog. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `not used` | Orbit connects to a single embedded server, not multiple | +| Orbit CLI | `reference` | Multi-server connection UI could be useful if CLI supports remote servers | diff --git a/Agent-backend/packages/app/src/components/server/server-row.tsx b/Agent-backend/packages/app/src/components/server/server-row.tsx new file mode 100644 index 000000000..5bb290ec3 --- /dev/null +++ b/Agent-backend/packages/app/src/components/server/server-row.tsx @@ -0,0 +1,124 @@ +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { + children, + createEffect, + createMemo, + createSignal, + type JSXElement, + onCleanup, + onMount, + type ParentProps, + Show, +} from "solid-js" +import { type ServerConnection, serverName } from "@/context/server" +import type { ServerHealth } from "@/utils/server-health" + +interface ServerRowProps extends ParentProps { + conn: ServerConnection.Any + status?: ServerHealth + class?: string + nameClass?: string + versionClass?: string + dimmed?: boolean + badge?: JSXElement + showCredentials?: boolean +} + +export function ServerRow(props: ServerRowProps) { + const [truncated, setTruncated] = createSignal(false) + let nameRef: HTMLSpanElement | undefined + let versionRef: HTMLSpanElement | undefined + const name = createMemo(() => serverName(props.conn)) + + const check = () => { + const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false + const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false + setTruncated(nameTruncated || versionTruncated) + } + + createEffect(() => { + name() + props.conn.http.url + props.status?.version + queueMicrotask(check) + }) + + onMount(() => { + check() + if (typeof ResizeObserver !== "function") return + const observer = new ResizeObserver(check) + if (nameRef) observer.observe(nameRef) + if (versionRef) observer.observe(versionRef) + onCleanup(() => observer.disconnect()) + }) + + const tooltipValue = () => ( + + {serverName(props.conn, true)} + + v{props.status?.version} + + + ) + + const badge = children(() => props.badge) + + return ( + +
+
+
+ + {name()} + + + + v{props.status?.version} + + + } + > + {(badge) => badge()} + +
+ + {(conn) => ( +
+ + {conn().http.username ? ( + {conn().http.username} + ) : ( + no username + )} + + {conn().http.password && ••••••••} +
+ )} +
+
+ {props.children} +
+
+ ) +} + +export function ServerHealthIndicator(props: { health?: ServerHealth }) { + return ( +
+ ) +} diff --git a/Agent-backend/packages/app/src/components/session-context-usage.tsx b/Agent-backend/packages/app/src/components/session-context-usage.tsx new file mode 100644 index 000000000..08ae4d319 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session-context-usage.tsx @@ -0,0 +1,117 @@ +import { Match, Show, Switch, createMemo } from "solid-js" +import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" +import { ProgressCircle } from "@opencode-ai/ui/progress-circle" +import { Button } from "@opencode-ai/ui/button" +import { useParams } from "@solidjs/router" + +import { useLayout } from "@/context/layout" +import { useSync } from "@/context/sync" +import { useLanguage } from "@/context/language" +import { getSessionContextMetrics } from "@/components/session/session-context-metrics" + +interface SessionContextUsageProps { + variant?: "button" | "indicator" + placement?: TooltipProps["placement"] +} + +function openSessionContext(args: { + view: ReturnType["view"]> + layout: ReturnType + tabs: ReturnType["tabs"]> +}) { + if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() + if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") + args.tabs.open("context") + args.tabs.setActive("context") +} + +export function SessionContextUsage(props: SessionContextUsageProps) { + const sync = useSync() + const params = useParams() + const layout = useLayout() + const language = useLanguage() + + const variant = createMemo(() => props.variant ?? "button") + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + + const usd = createMemo( + () => + new Intl.NumberFormat(language.intl(), { + style: "currency", + currency: "USD", + }), + ) + + const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) + const context = createMemo(() => metrics().context) + const cost = createMemo(() => { + return usd().format(metrics().totalCost) + }) + + const openContext = () => { + if (!params.id) return + + if (tabs().active() === "context") { + tabs().close("context") + return + } + openSessionContext({ + view: view(), + layout, + tabs: tabs(), + }) + } + + const circle = () => ( +
+ +
+ ) + + const tooltipValue = () => ( +
+ + {(ctx) => ( + <> +
+ {ctx().total.toLocaleString(language.intl())} + {language.t("context.usage.tokens")} +
+
+ {ctx().usage ?? 0}% + {language.t("context.usage.usage")} +
+ + )} +
+
+ {cost()} + {language.t("context.usage.cost")} +
+
+ ) + + return ( + + + + {circle()} + + + + + + + ) +} diff --git a/Agent-backend/packages/app/src/components/session/CLAUDE.md b/Agent-backend/packages/app/src/components/session/CLAUDE.md new file mode 100644 index 000000000..32d71cb87 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/CLAUDE.md @@ -0,0 +1,80 @@ +# session + +> **Path:** `Agent-backend/packages/app/src/components/session/` + +## Purpose + +Session UI chrome for the OpenCode web app — toolbar, context metrics, tab strips, and new-session view. These components show how the upstream app fetches session data from the SDK, computes token usage, manages session lifecycle actions, and renders draggable tab strips. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Patterns for session toolbar, token metrics, context breakdown, and tab management directly inform Orbit's React reimplementation | +| Orbit CLI | `not used` | CLI TUI has its own session chrome in `packages/opencode/src/cli/cmd/tui/` | + +## When to Reference This + +| If you're building... | Read this file | +| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| **Session toolbar** with model picker, thinking level, share/archive/fork actions | `session-header.tsx` | +| **Token usage display** (input/output/reasoning/cache breakdown, cost, % of context used) | `session-context-metrics.ts` + `session-context-format.ts` | +| **Context breakdown panel** showing per-message token usage with role-colored bars | `session-context-tab.tsx` + `session-context-breakdown.ts` | +| **Draggable session tabs** with close buttons and file icons | `session-sortable-tab.tsx` | +| **Draggable terminal tabs** with workspace labels and split actions | `session-sortable-terminal-tab.tsx` | +| **New session welcome screen** with greeting and suggested actions | `session-new-view.tsx` | +| **"Open in" app launcher** menu (VS Code, Cursor, Zed, Ghostty, iTerm2, etc.) | `session-header.tsx` — `OPEN_APPS` array and `MAC_APPS`/`WINDOWS_APPS`/`LINUX_APPS` configs | + +## Files + +| File | Size | Purpose | Tests | +| ----------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `session-header.tsx` | 29KB | **Largest component.** Session toolbar: model selector via `useSync().providers`/`useSync().models`, thinking level cycling, "open in" app launcher (14 apps × 3 OS), share popover, archive, fork, rename, context usage bar. Fetches providers and session data from SDK sync context. | — | +| `session-context-tab.tsx` | 12KB | Full context breakdown panel. Accordion per message showing raw JSON content, token stats per role (system/user/assistant/tool) with color-coded bars. Uses `getSessionContextMetrics()` and `estimateSessionContextBreakdown()`. | — | +| `session-context-metrics.ts` | 2KB | **Pure logic (no SolidJS).** Computes session metrics from SDK `Message[]`: total cost, last assistant token breakdown (input/output/reasoning/cache read/cache write), context usage percentage. Finds last assistant message with tokens via reverse iteration. | `session-context-metrics.test.ts` | +| `session-context-breakdown.ts` | 4KB | **Pure logic (no SolidJS).** Estimates token breakdown by message role using character-based heuristics (chars ÷ 4 ≈ tokens). Groups by system/user/assistant/tool. | `session-context-breakdown.test.ts` | +| `session-context-format.ts` | 0.6KB | **Pure logic (no SolidJS).** Formats token counts ("12.3k") and percentages for display. | — | +| `session-new-view.tsx` | 3.6KB | New session welcome view — time-based greeting, suggested action buttons. | — | +| `session-sortable-tab.tsx` | 2.6KB | Draggable session tab using `@thisbeyond/solid-dnd`. Shows `FileVisual` icon (dual-layer mono/color file icon), title, close button with tooltip keybind. | — | +| `session-sortable-terminal-tab.tsx` | 6.2KB | Draggable terminal tab with workspace label, PTY status indicator, close/split actions, rename popover. | — | +| `index.ts` | 0.3KB | Barrel export of all session components. | — | + +## Key Data Patterns + +### Token metrics (portable to React) + +`session-context-metrics.ts` is framework-agnostic. Takes SDK `Message[]` + provider list, returns: + +```typescript +{ totalCost: number, context: { input, output, reasoning, cacheRead, cacheWrite, total, usage: percent | null } } +``` + +This can be imported directly into Orbit's React code — no SolidJS dependency. + +### Session toolbar data flow + +`session-header.tsx` fetches data via: + +- `useSync().session` — current session state (title, status, worktree) +- `useSync().messages` — message list for context metrics +- `useSync().providers` — provider list for model labels +- `useGlobalSDK()` — SDK client for session actions (rename, archive, share, fork) +- `useLayout()` — panel visibility, sidebar state + +### "Open in" apps + +14 apps defined per-OS with `id`, `label`, `icon`, and `openWith` (macOS app name for `open -a`). The session header renders these as a dropdown menu. Orbit can reuse the app list and open-with logic. + +## Dependencies + +- **SDK types:** `@opencode-ai/sdk/v2/client` — `Message`, `AssistantMessage`, `Part`, `UserMessage` +- **UI library:** `@opencode-ai/ui` — Accordion, Button, DropdownMenu, Icon, IconButton, Popover, Tooltip, FileIcon, Markdown, ScrollView, Spinner, TextField +- **Utilities:** `@opencode-ai/util` — `path.getFilename()`, `encode.checksum()`, `array.findLast()` +- **SolidJS contexts:** `@/context/sync`, `@/context/layout`, `@/context/command`, `@/context/language`, `@/context/platform`, `@/context/server`, `@/context/global-sdk`, `@/context/file` +- **DnD:** `@thisbeyond/solid-dnd` — sortable tab drag-and-drop + +## Notes + +- **3 files are pure logic** — `session-context-metrics.ts`, `session-context-breakdown.ts`, and `session-context-format.ts` have zero SolidJS imports. They operate on SDK types and return plain objects. Directly reusable in React. +- **`session-header.tsx` is 29KB** — the second-largest component in the entire app. Consider decomposing when building Orbit's equivalent. +- **Token estimation is approximate** — `session-context-breakdown.ts` uses chars ÷ 4, not a real tokenizer. Good enough for UI display, not for billing. diff --git a/Agent-backend/packages/app/src/components/session/index.ts b/Agent-backend/packages/app/src/components/session/index.ts new file mode 100644 index 000000000..20124b6fd --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/index.ts @@ -0,0 +1,5 @@ +export { SessionHeader } from "./session-header" +export { SessionContextTab } from "./session-context-tab" +export { SortableTab, FileVisual } from "./session-sortable-tab" +export { SortableTerminalTab } from "./session-sortable-terminal-tab" +export { NewSessionView } from "./session-new-view" diff --git a/Agent-backend/packages/app/src/components/session/session-context-breakdown.test.ts b/Agent-backend/packages/app/src/components/session/session-context-breakdown.test.ts new file mode 100644 index 000000000..f38aecb55 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-context-breakdown.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { estimateSessionContextBreakdown } from "./session-context-breakdown" + +const user = (id: string) => { + return { + id, + role: "user", + time: { created: 1 }, + } as unknown as Message +} + +const assistant = (id: string) => { + return { + id, + role: "assistant", + time: { created: 1 }, + } as unknown as Message +} + +describe("estimateSessionContextBreakdown", () => { + test("estimates tokens and keeps remaining tokens as other", () => { + const messages = [user("u1"), assistant("a1")] + const parts = { + u1: [{ type: "text", text: "hello world" }] as unknown as Part[], + a1: [{ type: "text", text: "assistant response" }] as unknown as Part[], + } + + const output = estimateSessionContextBreakdown({ + messages, + parts, + input: 20, + systemPrompt: "system prompt", + }) + + const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens])) + expect(map.system).toBe(4) + expect(map.user).toBe(3) + expect(map.assistant).toBe(5) + expect(map.other).toBe(8) + }) + + test("scales segments when estimates exceed input", () => { + const messages = [user("u1"), assistant("a1")] + const parts = { + u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[], + a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[], + } + + const output = estimateSessionContextBreakdown({ + messages, + parts, + input: 10, + systemPrompt: "z".repeat(200), + }) + + const total = output.reduce((sum, segment) => sum + segment.tokens, 0) + expect(total).toBeLessThanOrEqual(10) + expect(output.every((segment) => segment.width <= 100)).toBeTrue() + }) +}) diff --git a/Agent-backend/packages/app/src/components/session/session-context-breakdown.ts b/Agent-backend/packages/app/src/components/session/session-context-breakdown.ts new file mode 100644 index 000000000..e263b2957 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-context-breakdown.ts @@ -0,0 +1,132 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2/client" + +export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other" + +export type SessionContextBreakdownSegment = { + key: SessionContextBreakdownKey + tokens: number + width: number + percent: number +} + +const estimateTokens = (chars: number) => Math.ceil(chars / 4) +const toPercent = (tokens: number, input: number) => (tokens / input) * 100 +const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10 + +const charsFromUserPart = (part: Part) => { + if (part.type === "text") return part.text.length + if (part.type === "file") return part.source?.text.value.length ?? 0 + if (part.type === "agent") return part.source?.value.length ?? 0 + return 0 +} + +const charsFromAssistantPart = (part: Part) => { + if (part.type === "text") return { assistant: part.text.length, tool: 0 } + if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 } + if (part.type !== "tool") return { assistant: 0, tool: 0 } + + const input = Object.keys(part.state.input).length * 16 + if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length } + if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length } + if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length } + return { assistant: 0, tool: input } +} + +const build = ( + tokens: { system: number; user: number; assistant: number; tool: number; other: number }, + input: number, +) => { + return [ + { + key: "system", + tokens: tokens.system, + }, + { + key: "user", + tokens: tokens.user, + }, + { + key: "assistant", + tokens: tokens.assistant, + }, + { + key: "tool", + tokens: tokens.tool, + }, + { + key: "other", + tokens: tokens.other, + }, + ] + .filter((x) => x.tokens > 0) + .map((x) => ({ + key: x.key, + tokens: x.tokens, + width: toPercent(x.tokens, input), + percent: toPercentLabel(x.tokens, input), + })) as SessionContextBreakdownSegment[] +} + +export function estimateSessionContextBreakdown(args: { + messages: Message[] + parts: Record + input: number + systemPrompt?: string +}) { + if (!args.input) return [] + + const counts = args.messages.reduce( + (acc, msg) => { + const parts = args.parts[msg.id] ?? [] + if (msg.role === "user") { + const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0) + return { ...acc, user: acc.user + user } + } + + if (msg.role !== "assistant") return acc + const assistant = parts.reduce( + (sum, part) => { + const next = charsFromAssistantPart(part) + return { + assistant: sum.assistant + next.assistant, + tool: sum.tool + next.tool, + } + }, + { assistant: 0, tool: 0 }, + ) + return { + ...acc, + assistant: acc.assistant + assistant.assistant, + tool: acc.tool + assistant.tool, + } + }, + { + system: args.systemPrompt?.length ?? 0, + user: 0, + assistant: 0, + tool: 0, + }, + ) + + const tokens = { + system: estimateTokens(counts.system), + user: estimateTokens(counts.user), + assistant: estimateTokens(counts.assistant), + tool: estimateTokens(counts.tool), + } + const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool + + if (estimated <= args.input) { + return build({ ...tokens, other: args.input - estimated }, args.input) + } + + const scale = args.input / estimated + const scaled = { + system: Math.floor(tokens.system * scale), + user: Math.floor(tokens.user * scale), + assistant: Math.floor(tokens.assistant * scale), + tool: Math.floor(tokens.tool * scale), + } + const total = scaled.system + scaled.user + scaled.assistant + scaled.tool + return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input) +} diff --git a/Agent-backend/packages/app/src/components/session/session-context-format.ts b/Agent-backend/packages/app/src/components/session/session-context-format.ts new file mode 100644 index 000000000..e7c536d58 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-context-format.ts @@ -0,0 +1,20 @@ +import { DateTime } from "luxon" + +export function createSessionContextFormatter(locale: string) { + return { + number(value: number | null | undefined) { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString(locale) + }, + percent(value: number | null | undefined) { + if (value === undefined) return "—" + if (value === null) return "—" + return value.toLocaleString(locale) + "%" + }, + time(value: number | undefined) { + if (!value) return "—" + return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED) + }, + } +} diff --git a/Agent-backend/packages/app/src/components/session/session-context-metrics.test.ts b/Agent-backend/packages/app/src/components/session/session-context-metrics.test.ts new file mode 100644 index 000000000..0e109a71b --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-context-metrics.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test" +import type { Message } from "@opencode-ai/sdk/v2/client" +import { getSessionContextMetrics } from "./session-context-metrics" + +const assistant = ( + id: string, + tokens: { input: number; output: number; reasoning: number; read: number; write: number }, + cost: number, + providerID = "openai", + modelID = "gpt-4.1", +) => { + return { + id, + role: "assistant", + providerID, + modelID, + cost, + tokens: { + input: tokens.input, + output: tokens.output, + reasoning: tokens.reasoning, + cache: { + read: tokens.read, + write: tokens.write, + }, + }, + time: { created: 1 }, + } as unknown as Message +} + +const user = (id: string) => { + return { + id, + role: "user", + cost: 0, + time: { created: 1 }, + } as unknown as Message +} + +describe("getSessionContextMetrics", () => { + test("computes totals and usage from latest assistant with tokens", () => { + const messages = [ + user("u1"), + assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), + assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25), + ] + const providers = [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-4.1": { + name: "GPT-4.1", + limit: { context: 1000 }, + }, + }, + }, + ] + + const metrics = getSessionContextMetrics(messages, providers) + + expect(metrics.totalCost).toBe(1.75) + expect(metrics.context?.message.id).toBe("a2") + expect(metrics.context?.total).toBe(500) + expect(metrics.context?.usage).toBe(50) + expect(metrics.context?.providerLabel).toBe("OpenAI") + expect(metrics.context?.modelLabel).toBe("GPT-4.1") + }) + + test("preserves fallback labels and null usage when model metadata is missing", () => { + const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")] + const providers = [{ id: "p-1", models: {} }] + + const metrics = getSessionContextMetrics(messages, providers) + + expect(metrics.context?.providerLabel).toBe("p-1") + expect(metrics.context?.modelLabel).toBe("m-1") + expect(metrics.context?.limit).toBeUndefined() + expect(metrics.context?.usage).toBeNull() + }) + + test("recomputes when message array is mutated in place", () => { + const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] + const providers = [{ id: "openai", models: {} }] + + const one = getSessionContextMetrics(messages, providers) + messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) + const two = getSessionContextMetrics(messages, providers) + + expect(one.context?.message.id).toBe("a1") + expect(two.context?.message.id).toBe("a2") + expect(two.totalCost).toBe(1) + }) + + test("returns empty metrics when inputs are undefined", () => { + const metrics = getSessionContextMetrics(undefined, undefined) + + expect(metrics.totalCost).toBe(0) + expect(metrics.context).toBeUndefined() + }) +}) diff --git a/Agent-backend/packages/app/src/components/session/session-context-metrics.ts b/Agent-backend/packages/app/src/components/session/session-context-metrics.ts new file mode 100644 index 000000000..0789b05f1 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-context-metrics.ts @@ -0,0 +1,82 @@ +import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" + +type Provider = { + id: string + name?: string + models: Record +} + +type Model = { + name?: string + limit: { + context: number + } +} + +type Context = { + message: AssistantMessage + provider?: Provider + model?: Model + providerLabel: string + modelLabel: string + limit: number | undefined + input: number + output: number + reasoning: number + cacheRead: number + cacheWrite: number + total: number + usage: number | null +} + +type Metrics = { + totalCost: number + context: Context | undefined +} + +const tokenTotal = (msg: AssistantMessage) => { + return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write +} + +const lastAssistantWithTokens = (messages: Message[]) => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role !== "assistant") continue + if (tokenTotal(msg) <= 0) continue + return msg + } +} + +const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => { + const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0) + const message = lastAssistantWithTokens(messages) + if (!message) return { totalCost, context: undefined } + + const provider = providers.find((item) => item.id === message.providerID) + const model = provider?.models[message.modelID] + const limit = model?.limit.context + const total = tokenTotal(message) + + return { + totalCost, + context: { + message, + provider, + model, + providerLabel: provider?.name ?? message.providerID, + modelLabel: model?.name ?? message.modelID, + limit, + input: message.tokens.input, + output: message.tokens.output, + reasoning: message.tokens.reasoning, + cacheRead: message.tokens.cache.read, + cacheWrite: message.tokens.cache.write, + total, + usage: limit ? Math.round((total / limit) * 100) : null, + }, + } +} + +export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) { + return build(messages, providers) +} diff --git a/Agent-backend/packages/app/src/components/session/session-context-tab.tsx b/Agent-backend/packages/app/src/components/session/session-context-tab.tsx new file mode 100644 index 000000000..39eb4b4c0 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-context-tab.tsx @@ -0,0 +1,343 @@ +import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" +import type { JSX } from "solid-js" +import { useParams } from "@solidjs/router" +import { useSync } from "@/context/sync" +import { useLayout } from "@/context/layout" +import { checksum } from "@opencode-ai/util/encode" +import { findLast } from "@opencode-ai/util/array" +import { same } from "@/utils/same" +import { Icon } from "@opencode-ai/ui/icon" +import { Accordion } from "@opencode-ai/ui/accordion" +import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" +import { File } from "@opencode-ai/ui/file" +import { Markdown } from "@opencode-ai/ui/markdown" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import { useLanguage } from "@/context/language" +import { getSessionContextMetrics } from "./session-context-metrics" +import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" +import { createSessionContextFormatter } from "./session-context-format" + +const BREAKDOWN_COLOR: Record = { + system: "var(--syntax-info)", + user: "var(--syntax-success)", + assistant: "var(--syntax-property)", + tool: "var(--syntax-warning)", + other: "var(--syntax-comment)", +} + +function Stat(props: { label: string; value: JSX.Element }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ) +} + +function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) { + const file = createMemo(() => { + const parts = props.getParts(props.message.id) + const contents = JSON.stringify({ message: props.message, parts }, null, 2) + return { + name: `${props.message.role}-${props.message.id}.json`, + contents, + cacheKey: checksum(contents), + } + }) + + return ( + requestAnimationFrame(props.onRendered)} + /> + ) +} + +function RawMessage(props: { + message: Message + getParts: (id: string) => Part[] + onRendered: () => void + time: (value: number | undefined) => string +}) { + return ( + + + +
+
+ {props.message.role} • {props.message.id} +
+
+
{props.time(props.message.time.created)}
+ +
+
+
+
+ +
+ +
+
+
+ ) +} + +const emptyMessages: Message[] = [] +const emptyUserMessages: UserMessage[] = [] + +export function SessionContextTab() { + const params = useParams() + const sync = useSync() + const layout = useLayout() + const language = useLanguage() + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const view = createMemo(() => layout.view(sessionKey)) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + + const messages = createMemo( + () => { + const id = params.id + if (!id) return emptyMessages + return (sync.data.message[id] ?? []) as Message[] + }, + emptyMessages, + { equals: same }, + ) + + const userMessages = createMemo( + () => messages().filter((m) => m.role === "user") as UserMessage[], + emptyUserMessages, + { equals: same }, + ) + + const visibleUserMessages = createMemo( + () => { + const revert = info()?.revert?.messageID + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }, + emptyUserMessages, + { equals: same }, + ) + + const usd = createMemo( + () => + new Intl.NumberFormat(language.intl(), { + style: "currency", + currency: "USD", + }), + ) + + const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) + const ctx = createMemo(() => metrics().context) + const formatter = createMemo(() => createSessionContextFormatter(language.intl())) + + const cost = createMemo(() => { + return usd().format(metrics().totalCost) + }) + + const counts = createMemo(() => { + const all = messages() + const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) + const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) + return { + all: all.length, + user, + assistant, + } + }) + + const systemPrompt = createMemo(() => { + const msg = findLast(visibleUserMessages(), (m) => !!m.system) + const system = msg?.system + if (!system) return + const trimmed = system.trim() + if (!trimmed) return + return trimmed + }) + + const providerLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + return c.providerLabel + }) + + const modelLabel = createMemo(() => { + const c = ctx() + if (!c) return "—" + return c.modelLabel + }) + + const breakdown = createMemo( + on( + () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], + () => { + const c = ctx() + if (!c?.input) return [] + return estimateSessionContextBreakdown({ + messages: messages(), + parts: sync.data.part as Record, + input: c.input, + systemPrompt: systemPrompt(), + }) + }, + ), + ) + + const breakdownLabel = (key: SessionContextBreakdownKey) => { + if (key === "system") return language.t("context.breakdown.system") + if (key === "user") return language.t("context.breakdown.user") + if (key === "assistant") return language.t("context.breakdown.assistant") + if (key === "tool") return language.t("context.breakdown.tool") + return language.t("context.breakdown.other") + } + + const stats = [ + { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, + { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, + { label: "context.stats.provider", value: providerLabel }, + { label: "context.stats.model", value: modelLabel }, + { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, + { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, + { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, + { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, + { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) }, + { + label: "context.stats.cacheTokens", + value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`, + }, + { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, + { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, + { label: "context.stats.totalCost", value: cost }, + { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) }, + { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, + ] satisfies { label: string; value: () => JSX.Element }[] + + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[] + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = view().scroll("context") + if (!s) return + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + view().setScroll("context", next) + }) + } + + createEffect( + on( + () => messages().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + return ( + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > +
+
+ + {(stat) => [0])} value={stat.value()} />} + +
+ + 0}> +
+
{language.t("context.breakdown.title")}
+
+ + {(segment) => ( +
+ )} + +
+
+ + {(segment) => ( +
+
+
{breakdownLabel(segment.key)}
+
{segment.percent.toLocaleString(language.intl())}%
+
+ )} + +
+ +
+ + + + {(prompt) => ( +
+
{language.t("context.systemPrompt.title")}
+
+ +
+
+ )} +
+ +
+
{language.t("context.rawMessages.title")}
+ + + {(message) => ( + + )} + + +
+
+ + ) +} diff --git a/Agent-backend/packages/app/src/components/session/session-header.tsx b/Agent-backend/packages/app/src/components/session/session-header.tsx new file mode 100644 index 000000000..9b4551584 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-header.tsx @@ -0,0 +1,717 @@ +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { Button } from "@opencode-ai/ui/button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Keybind } from "@opencode-ai/ui/keybind" +import { Popover } from "@opencode-ai/ui/popover" +import { Spinner } from "@opencode-ai/ui/spinner" +import { TextField } from "@opencode-ai/ui/text-field" +import { showToast } from "@opencode-ai/ui/toast" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { getFilename } from "@opencode-ai/util/path" +import { useParams } from "@solidjs/router" +import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { Portal } from "solid-js/web" +import { useCommand } from "@/context/command" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { useSync } from "@/context/sync" +import { decode64 } from "@/utils/base64" +import { Persist, persisted } from "@/utils/persist" +import { StatusPopover } from "../status-popover" + +const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "warp", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +type OpenApp = (typeof OPEN_APPS)[number] +type OS = "macos" | "windows" | "linux" | "unknown" + +const MAC_APPS = [ + { + id: "vscode", + label: "VS Code", + icon: "vscode", + openWith: "Visual Studio Code", + }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, + { + id: "antigravity", + label: "Antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, + { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "Warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, + { + id: "android-studio", + label: "Android Studio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "Sublime Text", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +const WINDOWS_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { + id: "powershell", + label: "PowerShell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "Sublime Text", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +const LINUX_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { + id: "sublime-text", + label: "Sublime Text", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number] +type OpenIcon = OpenApp | "file-explorer" +const OPEN_ICON_BASE = new Set(["finder", "vscode", "cursor", "zed"]) + +const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]") + +const detectOS = (platform: ReturnType): OS => { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +function useSessionShare(args: { + globalSDK: ReturnType + currentSession: () => + | { + share?: { + url?: string + } + } + | undefined + sessionID: () => string | undefined + projectDirectory: () => string + platform: ReturnType +}) { + const [state, setState] = createStore({ + share: false, + unshare: false, + copied: false, + timer: undefined as number | undefined, + }) + const shareUrl = createMemo(() => args.currentSession()?.share?.url) + + createEffect(() => { + const url = shareUrl() + if (url) return + if (state.timer) window.clearTimeout(state.timer) + setState({ copied: false, timer: undefined }) + }) + + onCleanup(() => { + if (state.timer) window.clearTimeout(state.timer) + }) + + const shareSession = () => { + const sessionID = args.sessionID() + if (!sessionID || state.share) return + setState("share", true) + args.globalSDK.client.session + .share({ sessionID, directory: args.projectDirectory() }) + .catch((error) => { + console.error("Failed to share session", error) + }) + .finally(() => { + setState("share", false) + }) + } + + const unshareSession = () => { + const sessionID = args.sessionID() + if (!sessionID || state.unshare) return + setState("unshare", true) + args.globalSDK.client.session + .unshare({ sessionID, directory: args.projectDirectory() }) + .catch((error) => { + console.error("Failed to unshare session", error) + }) + .finally(() => { + setState("unshare", false) + }) + } + + const copyLink = (onError: (error: unknown) => void) => { + const url = shareUrl() + if (!url) return + navigator.clipboard + .writeText(url) + .then(() => { + if (state.timer) window.clearTimeout(state.timer) + setState("copied", true) + const timer = window.setTimeout(() => { + setState("copied", false) + setState("timer", undefined) + }, 3000) + setState("timer", timer) + }) + .catch(onError) + } + + const viewShare = () => { + const url = shareUrl() + if (!url) return + args.platform.openLink(url) + } + + return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare } +} + +export function SessionHeader() { + const globalSDK = useGlobalSDK() + const layout = useLayout() + const params = useParams() + const command = useCommand() + const server = useServer() + const sync = useSync() + const platform = usePlatform() + const language = useLanguage() + + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const name = createMemo(() => { + const current = project() + if (current) return current.name || getFilename(current.worktree) + return getFilename(projectDirectory()) + }) + const hotkey = createMemo(() => command.keybind("file.open")) + + const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") + const showShare = createMemo(() => shareEnabled() && !!params.id) + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const view = createMemo(() => layout.view(sessionKey)) + const os = createMemo(() => detectOS(platform)) + + const [exists, setExists] = createStore>>({ + finder: true, + }) + + const apps = createMemo(() => { + if (os() === "macos") return MAC_APPS + if (os() === "windows") return WINDOWS_APPS + return LINUX_APPS + }) + + const fileManager = createMemo(() => { + if (os() === "macos") return { label: "Finder", icon: "finder" as const } + if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const } + return { label: "File Manager", icon: "finder" as const } + }) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = apps() + + setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) + + void Promise.all( + list.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)) + .then((value) => Boolean(value)) + .catch(() => false) + .then((ok) => { + console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`) + return [app.id, ok] as const + }), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + + const options = createMemo(() => { + return [ + { id: "finder", label: fileManager().label, icon: fileManager().icon }, + ...apps().filter((app) => exists[app.id]), + ] as const + }) + + const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) + const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) + const opening = createMemo(() => openRequest.app !== undefined) + + const selectApp = (app: OpenApp) => { + if (!options().some((item) => item.id === app)) return + setPrefs("app", app) + } + + const openDir = (app: OpenApp) => { + if (opening() || !canOpen() || !platform.openPath) return + const directory = projectDirectory() + if (!directory) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) + } + + const copyPath = () => { + const directory = projectDirectory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => showRequestError(language, err)) + } + + const share = useSessionShare({ + globalSDK, + currentSession, + sessionID: () => params.id, + projectDirectory, + platform, + }) + + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) + const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) + + return ( + <> + + {(mount) => ( + + + + )} + + + {(mount) => ( + +
+ + + + } + > +
+
+ +
+ setMenu("open", open)} + > + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + selectApp(value as OpenApp) + }} + > + + {(o) => ( + { + setMenu("open", false) + openDir(o.id) + }} + > +
+ +
+ {o.label} + + + +
+ )} +
+
+
+ + { + setMenu("open", false) + copyPath() + }} + > +
+ +
+ + {language.t("session.header.open.copyPath")} + +
+
+
+
+
+
+ +
+
+ +
+ {language.t("session.share.action.share")}} + > +
+ + +
+ } + > +
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+
+ + )} +
+ + ) +} diff --git a/Agent-backend/packages/app/src/components/session/session-new-view.tsx b/Agent-backend/packages/app/src/components/session/session-new-view.tsx new file mode 100644 index 000000000..52251dbb2 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-new-view.tsx @@ -0,0 +1,92 @@ +import { Show, createMemo } from "solid-js" +import { DateTime } from "luxon" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { useLanguage } from "@/context/language" +import { Icon } from "@opencode-ai/ui/icon" +import { Mark } from "@opencode-ai/ui/logo" +import { getDirectory, getFilename } from "@opencode-ai/util/path" + +const MAIN_WORKTREE = "main" +const CREATE_WORKTREE = "create" +const ROOT_CLASS = "size-full flex flex-col" + +interface NewSessionViewProps { + worktree: string + onWorktreeChange: (value: string) => void +} + +export function NewSessionView(props: NewSessionViewProps) { + const sync = useSync() + const sdk = useSDK() + const language = useLanguage() + + const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) + const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) + const current = createMemo(() => { + const selection = props.worktree + if (options().includes(selection)) return selection + return MAIN_WORKTREE + }) + const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory) + const isWorktree = createMemo(() => { + const project = sync.project + if (!project) return false + return sdk.directory !== project.worktree + }) + + const label = (value: string) => { + if (value === MAIN_WORKTREE) { + if (isWorktree()) return language.t("session.new.worktree.main") + const branch = sync.data.vcs?.branch + if (branch) return language.t("session.new.worktree.mainWithBranch", { branch }) + return language.t("session.new.worktree.main") + } + + if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create") + + return getFilename(value) + } + + return ( +
+
+
+
+
+ +
{language.t("session.new.title")}
+
+
+
+
+ {getDirectory(projectRoot())} + {getFilename(projectRoot())} +
+
+
+ +
+ {label(current())} +
+
+ + {(project) => ( +
+
+ {language.t("session.new.lastModified")}  + + {DateTime.fromMillis(project().time.updated ?? project().time.created) + .setLocale(language.intl()) + .toRelative()} + +
+
+ )} +
+
+
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/session/session-sortable-tab.tsx b/Agent-backend/packages/app/src/components/session/session-sortable-tab.tsx new file mode 100644 index 000000000..dfda91c16 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-sortable-tab.tsx @@ -0,0 +1,70 @@ +import { createMemo, Show } from "solid-js" +import type { JSX } from "solid-js" +import { createSortable } from "@thisbeyond/solid-dnd" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { Tabs } from "@opencode-ai/ui/tabs" +import { getFilename } from "@opencode-ai/util/path" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" + +export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { + return ( +
+ } + > + + + + + + {getFilename(props.path)} +
+ ) +} + +export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { + const file = useFile() + const language = useLanguage() + const command = useCommand() + const sortable = createSortable(props.tab) + const path = createMemo(() => file.pathFromTab(props.tab)) + const content = createMemo(() => { + const value = path() + if (!value) return + return + }) + return ( +
+
+ + props.onTabClose(props.tab)} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => props.onTabClose(props.tab)} + > + {(value) => value()} + +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/Agent-backend/packages/app/src/components/session/session-sortable-terminal-tab.tsx new file mode 100644 index 000000000..6fe6186d5 --- /dev/null +++ b/Agent-backend/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -0,0 +1,201 @@ +import type { JSX } from "solid-js" +import { Show, createEffect, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { createSortable } from "@thisbeyond/solid-dnd" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tabs } from "@opencode-ai/ui/tabs" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useLanguage } from "@/context/language" + +export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element { + const terminal = useTerminal() + const language = useLanguage() + const sortable = createSortable(props.terminal.id) + const [store, setStore] = createStore({ + editing: false, + title: props.terminal.title, + menuOpen: false, + menuPosition: { x: 0, y: 0 }, + blurEnabled: false, + }) + let input: HTMLInputElement | undefined + let blurFrame: number | undefined + + const isDefaultTitle = () => { + const number = props.terminal.titleNumber + if (!Number.isFinite(number) || number <= 0) return false + const match = props.terminal.title.match(/^Terminal (\d+)$/) + if (!match) return false + const parsed = Number(match[1]) + if (!Number.isFinite(parsed) || parsed <= 0) return false + return parsed === number + } + + const label = () => { + language.locale() + if (props.terminal.title && !isDefaultTitle()) return props.terminal.title + + const number = props.terminal.titleNumber + if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) + if (props.terminal.title) return props.terminal.title + return language.t("terminal.title") + } + + const close = () => { + const count = terminal.all().length + terminal.close(props.terminal.id) + if (count === 1) { + props.onClose?.() + } + } + + const focus = () => { + if (store.editing) return + + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`) + const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement + if (!element) return + + const textarea = element.querySelector("textarea") as HTMLTextAreaElement + if (textarea) { + textarea.focus() + return + } + element.focus() + element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) + } + + const edit = (e?: Event) => { + if (e) { + e.stopPropagation() + e.preventDefault() + } + + setStore("blurEnabled", false) + setStore("title", props.terminal.title) + setStore("editing", true) + } + + const save = () => { + if (!store.blurEnabled) return + + const value = store.title.trim() + if (value && value !== props.terminal.title) { + terminal.update({ id: props.terminal.id, title: value }) + } + setStore("editing", false) + } + + const keydown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + save() + return + } + if (e.key === "Escape") { + e.preventDefault() + setStore("editing", false) + } + } + + const menu = (e: MouseEvent) => { + e.preventDefault() + setStore("menuPosition", { x: e.clientX, y: e.clientY }) + setStore("menuOpen", true) + } + + createEffect(() => { + if (!store.editing) return + if (!input) return + input.focus() + input.select() + if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) + blurFrame = requestAnimationFrame(() => { + blurFrame = undefined + setStore("blurEnabled", true) + }) + }) + + onCleanup(() => { + if (blurFrame === undefined) return + cancelAnimationFrame(blurFrame) + }) + + return ( +
+
+ e.preventDefault()} + onContextMenu={menu} + class="!shadow-none" + classes={{ + button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", + }} + closeButton={ + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + } + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" + /> +
+
+ setStore("menuOpen", open)}> + + + + + {language.t("common.rename")} + + + + {language.t("common.close")} + + + + +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/settings-agents.tsx b/Agent-backend/packages/app/src/components/settings-agents.tsx new file mode 100644 index 000000000..74a942f77 --- /dev/null +++ b/Agent-backend/packages/app/src/components/settings-agents.tsx @@ -0,0 +1,16 @@ +import { Component } from "solid-js" +import { useLanguage } from "@/context/language" + +export const SettingsAgents: Component = () => { + // TODO: Replace this placeholder with full agents settings controls. + const language = useLanguage() + + return ( +
+
+

{language.t("settings.agents.title")}

+

{language.t("settings.agents.description")}

+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/settings-commands.tsx b/Agent-backend/packages/app/src/components/settings-commands.tsx new file mode 100644 index 000000000..e158d231c --- /dev/null +++ b/Agent-backend/packages/app/src/components/settings-commands.tsx @@ -0,0 +1,16 @@ +import { Component } from "solid-js" +import { useLanguage } from "@/context/language" + +export const SettingsCommands: Component = () => { + // TODO: Replace this placeholder with full commands settings controls. + const language = useLanguage() + + return ( +
+
+

{language.t("settings.commands.title")}

+

{language.t("settings.commands.description")}

+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/settings-general.tsx b/Agent-backend/packages/app/src/components/settings-general.tsx new file mode 100644 index 000000000..42ee4092f --- /dev/null +++ b/Agent-backend/packages/app/src/components/settings-general.tsx @@ -0,0 +1,562 @@ +import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Select } from "@opencode-ai/ui/select" +import { Switch } from "@opencode-ai/ui/switch" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { showToast } from "@opencode-ai/ui/toast" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSettings, monoFontFamily } from "@/context/settings" +import { playSound, SOUND_OPTIONS } from "@/utils/sound" +import { Link } from "./link" + +let demoSoundState = { + cleanup: undefined as (() => void) | undefined, + timeout: undefined as NodeJS.Timeout | undefined, +} + +// To prevent audio from overlapping/playing very quickly when navigating the settings menus, +// delay the playback by 100ms during quick selection changes and pause existing sounds. +const stopDemoSound = () => { + if (demoSoundState.cleanup) { + demoSoundState.cleanup() + } + clearTimeout(demoSoundState.timeout) + demoSoundState.cleanup = undefined +} + +const playDemoSound = (src: string | undefined) => { + stopDemoSound() + if (!src) return + + demoSoundState.timeout = setTimeout(() => { + demoSoundState.cleanup = playSound(src) + }, 100) +} + +export const SettingsGeneral: Component = () => { + const theme = useTheme() + const language = useLanguage() + const platform = usePlatform() + const settings = useSettings() + + const [store, setStore] = createStore({ + checking: false, + }) + + const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") + + const check = () => { + if (!platform.checkUpdate) return + setStore("checking", true) + + void platform + .checkUpdate() + .then((result) => { + if (!result.updateAvailable) { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("settings.updates.toast.latest.title"), + description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }), + }) + return + } + + const actions = + platform.update && platform.restart + ? [ + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() + }, + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] + : [ + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] + + showToast({ + persistent: true, + icon: "download", + title: language.t("toast.update.title"), + description: language.t("toast.update.description", { version: result.version ?? "" }), + actions, + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => setStore("checking", false)) + } + + const themeOptions = createMemo(() => + Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), + ) + + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ + { value: "system", label: language.t("theme.scheme.system") }, + { value: "light", label: language.t("theme.scheme.light") }, + { value: "dark", label: language.t("theme.scheme.dark") }, + ]) + + const languageOptions = createMemo(() => + language.locales.map((locale) => ({ + value: locale, + label: language.label(locale), + })), + ) + + const fontOptions = [ + { value: "ibm-plex-mono", label: "font.option.ibmPlexMono" }, + { value: "cascadia-code", label: "font.option.cascadiaCode" }, + { value: "fira-code", label: "font.option.firaCode" }, + { value: "hack", label: "font.option.hack" }, + { value: "inconsolata", label: "font.option.inconsolata" }, + { value: "intel-one-mono", label: "font.option.intelOneMono" }, + { value: "iosevka", label: "font.option.iosevka" }, + { value: "jetbrains-mono", label: "font.option.jetbrainsMono" }, + { value: "meslo-lgs", label: "font.option.mesloLgs" }, + { value: "roboto-mono", label: "font.option.robotoMono" }, + { value: "source-code-pro", label: "font.option.sourceCodePro" }, + { value: "ubuntu-mono", label: "font.option.ubuntuMono" }, + { value: "geist-mono", label: "font.option.geistMono" }, + ] as const + const fontOptionsList = [...fontOptions] + + const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const + const soundOptions = [noneSound, ...SOUND_OPTIONS] + + const soundSelectProps = ( + enabled: () => boolean, + current: () => string, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ + options: soundOptions, + current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, + value: (o: (typeof soundOptions)[number]) => o.id, + label: (o: (typeof soundOptions)[number]) => language.t(o.label), + onHighlight: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + playDemoSound(option.src) + }, + onSelect: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + if (option.id === "none") { + setEnabled(false) + stopDemoSound() + return + } + setEnabled(true) + set(option.id) + playDemoSound(option.src) + }, + variant: "secondary" as const, + size: "small" as const, + triggerVariant: "settings" as const, + }) + + const AppearanceSection = () => ( +
+

{language.t("settings.general.section.appearance")}

+ +
+ + o.value === theme.colorScheme())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && theme.setColorScheme(option.value)} + onHighlight={(option) => { + if (!option) return + theme.previewColorScheme(option.value) + return () => theme.cancelPreview() + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + + + + {language.t("settings.general.row.theme.description")}{" "} + {language.t("common.learnMore")} + + } + > + o.value === settings.appearance.font())} + value={(o) => o.value} + label={(o) => language.t(o.label)} + onSelect={(option) => option && settings.appearance.setFont(option.value)} + variant="secondary" + size="small" + triggerVariant="settings" + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} + > + {(option) => ( + + {option ? language.t(option.label) : ""} + + )} + + +
+
+ ) + + const FeedSection = () => ( +
+

{language.t("settings.general.section.feed")}

+ +
+ +
+ settings.general.setShowReasoningSummaries(checked)} + /> +
+
+ + +
+ settings.general.setShellToolPartsExpanded(checked)} + /> +
+
+ + +
+ settings.general.setEditToolPartsExpanded(checked)} + /> +
+
+
+
+ ) + + const NotificationsSection = () => ( +
+

{language.t("settings.general.section.notifications")}

+ +
+ +
+ settings.notifications.setAgent(checked)} + /> +
+
+ + +
+ settings.notifications.setPermissions(checked)} + /> +
+
+ + +
+ settings.notifications.setErrors(checked)} + /> +
+
+
+
+ ) + + const SoundsSection = () => ( +
+

{language.t("settings.general.section.sounds")}

+ +
+ + settings.sounds.permissionsEnabled(), + () => settings.sounds.permissions(), + (value) => settings.sounds.setPermissionsEnabled(value), + (id) => settings.sounds.setPermissions(id), + )} + /> + + + + o.value === actionFor(item.id))} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && setPermission(item.id, option.value)} + variant="secondary" + size="small" + triggerVariant="settings" + /> + + )} + +
+
+
+
+ ) +} + +interface SettingsRowProps { + title: string + description: string + children: JSX.Element +} + +const SettingsRow: Component = (props) => { + return ( +
+
+ {props.title} + {props.description} +
+
{props.children}
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/settings-providers.tsx b/Agent-backend/packages/app/src/components/settings-providers.tsx new file mode 100644 index 000000000..a9839758b --- /dev/null +++ b/Agent-backend/packages/app/src/components/settings-providers.tsx @@ -0,0 +1,250 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tag } from "@opencode-ai/ui/tag" +import { showToast } from "@opencode-ai/ui/toast" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { createMemo, type Component, For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { DialogConnectProvider } from "./dialog-connect-provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogCustomProvider } from "./dialog-custom-provider" + +type ProviderSource = "env" | "api" | "config" | "custom" +type ProviderItem = ReturnType["connected"]>[number] + +const PROVIDER_NOTES = [ + { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" }, + { match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" }, + { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, + { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" }, + { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, + { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, + { match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" }, + { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, +] as const + +export const SettingsProviders: Component = () => { + const dialog = useDialog() + const language = useLanguage() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const providers = useProviders() + + const connected = createMemo(() => { + return providers + .connected() + .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) + }) + + const popular = createMemo(() => { + const connectedIDs = new Set(connected().map((p) => p.id)) + const items = providers + .popular() + .filter((p) => !connectedIDs.has(p.id)) + .slice() + items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)) + return items + }) + + const source = (item: ProviderItem): ProviderSource | undefined => { + if (!("source" in item)) return + const value = item.source + if (value === "env" || value === "api" || value === "config" || value === "custom") return value + return + } + + const type = (item: ProviderItem) => { + const current = source(item) + if (current === "env") return language.t("settings.providers.tag.environment") + if (current === "api") return language.t("provider.connect.method.apiKey") + if (current === "config") { + if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom") + return language.t("settings.providers.tag.config") + } + if (current === "custom") return language.t("settings.providers.tag.custom") + return language.t("settings.providers.tag.other") + } + + const canDisconnect = (item: ProviderItem) => source(item) !== "env" + + const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key + + const isConfigCustom = (providerID: string) => { + const provider = globalSync.data.config.provider?.[providerID] + if (!provider) return false + if (provider.npm !== "@ai-sdk/openai-compatible") return false + if (!provider.models || Object.keys(provider.models).length === 0) return false + return true + } + + const disableProvider = async (providerID: string, name: string) => { + const before = globalSync.data.config.disabled_providers ?? [] + const next = before.includes(providerID) ? before : [...before, providerID] + globalSync.set("config", "disabled_providers", next) + + await globalSync + .updateConfig({ disabled_providers: next }) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + }) + }) + .catch((err: unknown) => { + globalSync.set("config", "disabled_providers", before) + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } + + const disconnect = async (providerID: string, name: string) => { + if (isConfigCustom(providerID)) { + await globalSDK.client.auth.remove({ providerID }).catch(() => undefined) + await disableProvider(providerID, name) + return + } + await globalSDK.client.auth + .remove({ providerID }) + .then(async () => { + await globalSDK.client.global.dispose() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } + + return ( +
+
+
+

{language.t("settings.providers.title")}

+
+
+ +
+
+

{language.t("settings.providers.section.connected")}

+
+ 0} + fallback={ +
+ {language.t("settings.providers.connected.empty")} +
+ } + > + + {(item) => ( +
+
+ + {item.name} + {type(item)} +
+ + {language.t("settings.providers.connected.environmentDescription")} + + } + > + + +
+ )} +
+
+
+
+ +
+

{language.t("settings.providers.section.popular")}

+
+ + {(item) => ( +
+
+
+ + {item.name} + + {language.t("dialog.provider.tag.recommended")} + + + {language.t("dialog.provider.tag.recommended")} + +
+ + {(key) => {language.t(key())}} + +
+ +
+ )} +
+ +
+
+
+ + {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")} +
+ + {language.t("settings.providers.custom.description")} + +
+ +
+
+ + +
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/components/status-popover.tsx b/Agent-backend/packages/app/src/components/status-popover.tsx new file mode 100644 index 000000000..6c571f78c --- /dev/null +++ b/Agent-backend/packages/app/src/components/status-popover.tsx @@ -0,0 +1,415 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Icon } from "@opencode-ai/ui/icon" +import { Popover } from "@opencode-ai/ui/popover" +import { Switch } from "@opencode-ai/ui/switch" +import { Tabs } from "@opencode-ai/ui/tabs" +import { showToast } from "@opencode-ai/ui/toast" +import { useNavigate } from "@solidjs/router" +import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSDK } from "@/context/sdk" +import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { useSync } from "@/context/sync" +import { checkServerHealth, type ServerHealth } from "@/utils/server-health" +import { DialogSelectServer } from "./dialog-select-server" + +const pollMs = 10_000 + +const pluginEmptyMessage = (value: string, file: string): JSXElement => { + const parts = value.split(file) + if (parts.length === 1) return value + return ( + <> + {parts[0]} + {file} + {parts.slice(1).join(file)} + + ) +} + +const listServersByHealth = ( + list: ServerConnection.Any[], + active: ServerConnection.Key | undefined, + status: Record, +) => { + if (!list.length) return list + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerHealth) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + + return list.slice().sort((a, b) => { + if (ServerConnection.key(a) === active) return -1 + if (ServerConnection.key(b) === active) return 1 + const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) +} + +const useServerHealth = (servers: Accessor, fetcher: typeof fetch) => { + const [status, setStatus] = createStore({} as Record) + + createEffect(() => { + const list = servers() + let dead = false + + const refresh = async () => { + const results: Record = {} + await Promise.all( + list.map(async (conn) => { + results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) + }), + ) + if (dead) return + setStatus(reconcile(results)) + } + + void refresh() + const id = setInterval(() => void refresh(), pollMs) + onCleanup(() => { + dead = true + clearInterval(id) + }) + }) + + return status +} + +const useDefaultServerKey = ( + get: (() => string | Promise | null | undefined) | undefined, +) => { + const [url, setUrl] = createSignal() + const [tick, setTick] = createSignal(0) + + createEffect(() => { + tick() + let dead = false + const result = get?.() + if (!result) { + setUrl(undefined) + onCleanup(() => { + dead = true + }) + return + } + + if (result instanceof Promise) { + void result.then((next) => { + if (dead) return + setUrl(next ? normalizeServerUrl(next) : undefined) + }) + onCleanup(() => { + dead = true + }) + return + } + + setUrl(normalizeServerUrl(result)) + onCleanup(() => { + dead = true + }) + }) + + return { + key: () => { + const u = url() + if (!u) return + return ServerConnection.key({ type: "http", http: { url: u } }) + }, + refresh: () => setTick((value) => value + 1), + } +} + +const useMcpToggle = (input: { + sync: ReturnType + sdk: ReturnType + language: ReturnType +}) => { + const [loading, setLoading] = createSignal(null) + + const toggle = async (name: string) => { + if (loading()) return + setLoading(name) + + try { + const status = input.sync.data.mcp[name] + await (status?.status === "connected" + ? input.sdk.client.mcp.disconnect({ name }) + : input.sdk.client.mcp.connect({ name })) + const result = await input.sdk.client.mcp.status() + if (result.data) input.sync.set("mcp", result.data) + } catch (err) { + showToast({ + variant: "error", + title: input.language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + } finally { + setLoading(null) + } + } + + return { loading, toggle } +} + +export function StatusPopover() { + const sync = useSync() + const sdk = useSDK() + const server = useServer() + const platform = usePlatform() + const dialog = useDialog() + const language = useLanguage() + const navigate = useNavigate() + + const fetcher = platform.fetch ?? globalThis.fetch + const servers = createMemo(() => { + const current = server.current + const list = server.list + if (!current) return list + if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] + return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] + }) + const health = useServerHealth(servers, fetcher) + const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) + const mcp = useMcpToggle({ sync, sdk, language }) + const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl) + const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) + const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status + const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) + const lspItems = createMemo(() => sync.data.lsp ?? []) + const lspCount = createMemo(() => lspItems().length) + const plugins = createMemo(() => sync.data.config.plugin ?? []) + const pluginCount = createMemo(() => plugins().length) + const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "orbit.json")) + const overallHealthy = createMemo(() => { + const serverHealthy = server.healthy() === true + const anyMcpIssue = mcpNames().some((name) => { + const status = mcpStatus(name) + return status !== "connected" && status !== "disabled" + }) + return serverHealthy && !anyMcpIssue + }) + + return ( + +
+
+ } + class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl" + gutter={4} + placement="bottom-end" + shift={-168} + > +
+ + + + {sortedServers().length > 0 ? `${sortedServers().length} ` : ""} + {language.t("status.popover.tab.servers")} + + + {mcpConnected() > 0 ? `${mcpConnected()} ` : ""} + {language.t("status.popover.tab.mcp")} + + + {lspCount() > 0 ? `${lspCount()} ` : ""} + {language.t("status.popover.tab.lsp")} + + + {pluginCount() > 0 ? `${pluginCount()} ` : ""} + {language.t("status.popover.tab.plugins")} + + + + +
+
+ + {(s) => { + const key = ServerConnection.key(s) + const isBlocked = () => health[key]?.healthy === false + return ( + + ) + }} + + + +
+
+
+ + +
+
+ 0} + fallback={ +
+ {language.t("dialog.mcp.empty")} +
+ } + > + + {(name) => { + const status = () => mcpStatus(name) + const enabled = () => status() === "connected" + return ( + + ) + }} + +
+
+
+
+ + +
+
+ 0} + fallback={ +
+ {language.t("dialog.lsp.empty")} +
+ } + > + + {(item) => ( +
+
+ {item.name || item.id} +
+ )} + + +
+
+ + + +
+
+ 0} + fallback={
{pluginEmpty()}
} + > + + {(plugin) => ( +
+
+ {plugin} +
+ )} + + +
+
+ + +
+ + ) +} diff --git a/Agent-backend/packages/app/src/components/terminal.tsx b/Agent-backend/packages/app/src/components/terminal.tsx new file mode 100644 index 000000000..9e5f12ee4 --- /dev/null +++ b/Agent-backend/packages/app/src/components/terminal.tsx @@ -0,0 +1,574 @@ +import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +import { showToast } from "@opencode-ai/ui/toast" +import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" +import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" +import { SerializeAddon } from "@/addons/serialize" +import { matchKeybind, parseKeybind } from "@/context/command" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSDK } from "@/context/sdk" +import { useServer } from "@/context/server" +import { monoFontFamily, useSettings } from "@/context/settings" +import type { LocalPTY } from "@/context/terminal" +import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" +import { terminalWriter } from "@/utils/terminal-writer" + +const TOGGLE_TERMINAL_ID = "terminal.toggle" +const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" +export interface TerminalProps extends ComponentProps<"div"> { + pty: LocalPTY + onSubmit?: () => void + onCleanup?: (pty: Partial & { id: string }) => void + onConnect?: () => void + onConnectError?: (error: unknown) => void +} + +let shared: Promise<{ mod: typeof import("ghostty-web"); ghostty: Ghostty }> | undefined + +const loadGhostty = () => { + if (shared) return shared + shared = import("ghostty-web") + .then(async (mod) => ({ mod, ghostty: await mod.Ghostty.load() })) + .catch((err) => { + shared = undefined + throw err + }) + return shared +} + +type TerminalColors = { + background: string + foreground: string + cursor: string + selectionBackground: string +} + +const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { + light: { + background: "#fcfcfc", + foreground: "#211e1e", + cursor: "#211e1e", + selectionBackground: withAlpha("#211e1e", 0.2), + }, + dark: { + background: "#191515", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + selectionBackground: withAlpha("#d4d4d4", 0.25), + }, +} + +const debugTerminal = (...values: unknown[]) => { + if (!import.meta.env.DEV) return + console.debug("[terminal]", ...values) +} + +const useTerminalUiBindings = (input: { + container: HTMLDivElement + term: Term + cleanups: VoidFunction[] + handlePointerDown: () => void + handleLinkClick: (event: MouseEvent) => void +}) => { + const handleCopy = (event: ClipboardEvent) => { + const selection = input.term.getSelection() + if (!selection) return + + const clipboard = event.clipboardData + if (!clipboard) return + + event.preventDefault() + clipboard.setData("text/plain", selection) + } + + const handlePaste = (event: ClipboardEvent) => { + const clipboard = event.clipboardData + const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" + if (!text) return + + event.preventDefault() + event.stopPropagation() + input.term.paste(text) + } + + const handleTextareaFocus = () => { + input.term.options.cursorBlink = true + } + const handleTextareaBlur = () => { + input.term.options.cursorBlink = false + } + + input.container.addEventListener("copy", handleCopy, true) + input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true)) + + input.container.addEventListener("paste", handlePaste, true) + input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true)) + + input.container.addEventListener("pointerdown", input.handlePointerDown) + input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) + + input.container.addEventListener("click", input.handleLinkClick, { + capture: true, + }) + input.cleanups.push(() => + input.container.removeEventListener("click", input.handleLinkClick, { + capture: true, + }), + ) + + input.term.textarea?.addEventListener("focus", handleTextareaFocus) + input.term.textarea?.addEventListener("blur", handleTextareaBlur) + input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus)) + input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur)) +} + +const persistTerminal = (input: { + term: Term | undefined + addon: SerializeAddon | undefined + cursor: number + id: string + onCleanup?: (pty: Partial & { id: string }) => void +}) => { + if (!input.addon || !input.onCleanup || !input.term) return + const buffer = (() => { + try { + return input.addon.serialize() + } catch { + debugTerminal("failed to serialize terminal buffer") + return "" + } + })() + + input.onCleanup({ + id: input.id, + buffer, + cursor: input.cursor, + rows: input.term.rows, + cols: input.term.cols, + scrollY: input.term.getViewportY(), + }) +} + +export const Terminal = (props: TerminalProps) => { + const platform = usePlatform() + const sdk = useSDK() + const settings = useSettings() + const theme = useTheme() + const language = useLanguage() + const server = useServer() + let container!: HTMLDivElement + const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) + const id = local.pty.id + const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" + const restoreSize = + restore && + typeof local.pty.cols === "number" && + Number.isSafeInteger(local.pty.cols) && + local.pty.cols > 0 && + typeof local.pty.rows === "number" && + Number.isSafeInteger(local.pty.rows) && + local.pty.rows > 0 + ? { cols: local.pty.cols, rows: local.pty.rows } + : undefined + const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined + let ws: WebSocket | undefined + let term: Term | undefined + let ghostty: Ghostty + let serializeAddon: SerializeAddon + let fitAddon: FitAddon + let handleResize: () => void + let fitFrame: number | undefined + let sizeTimer: ReturnType | undefined + let pendingSize: { cols: number; rows: number } | undefined + let lastSize: { cols: number; rows: number } | undefined + let disposed = false + const cleanups: VoidFunction[] = [] + const start = + typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined + let cursor = start ?? 0 + let output: ReturnType | undefined + + const cleanup = () => { + if (!cleanups.length) return + const fns = cleanups.splice(0).reverse() + for (const fn of fns) { + try { + fn() + } catch (err) { + debugTerminal("cleanup failed", err) + } + } + } + + const pushSize = (cols: number, rows: number) => { + return sdk.client.pty + .update({ + ptyID: id, + size: { cols, rows }, + }) + .catch((err) => { + debugTerminal("failed to sync terminal size", err) + }) + } + + const getTerminalColors = (): TerminalColors => { + const mode = theme.mode() === "dark" ? "dark" : "light" + const fallback = DEFAULT_TERMINAL_COLORS[mode] + const currentTheme = theme.themes()[theme.themeId()] + if (!currentTheme) return fallback + const variant = mode === "dark" ? currentTheme.dark : currentTheme.light + if (!variant?.seeds && !variant?.palette) return fallback + const resolved = resolveThemeVariant(variant, mode === "dark") + const text = resolved["text-stronger"] ?? fallback.foreground + const background = resolved["background-stronger"] ?? fallback.background + const alpha = mode === "dark" ? 0.25 : 0.2 + const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) + const selectionBackground = withAlpha(base, alpha) + return { + background, + foreground: text, + cursor: text, + selectionBackground, + } + } + + const terminalColors = createMemo(getTerminalColors) + + const scheduleFit = () => { + if (disposed) return + if (!fitAddon) return + if (fitFrame !== undefined) return + + fitFrame = requestAnimationFrame(() => { + fitFrame = undefined + if (disposed) return + fitAddon.fit() + }) + } + + const scheduleSize = (cols: number, rows: number) => { + if (disposed) return + if (lastSize?.cols === cols && lastSize?.rows === rows) return + + pendingSize = { cols, rows } + + if (!lastSize) { + lastSize = pendingSize + void pushSize(cols, rows) + return + } + + if (sizeTimer !== undefined) return + sizeTimer = setTimeout(() => { + sizeTimer = undefined + const next = pendingSize + if (!next) return + pendingSize = undefined + if (disposed) return + if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return + lastSize = next + void pushSize(next.cols, next.rows) + }, 100) + } + + createEffect(() => { + const colors = terminalColors() + if (!term) return + setOptionIfSupported(term, "theme", colors) + }) + + createEffect(() => { + const font = monoFontFamily(settings.appearance.font()) + if (!term) return + setOptionIfSupported(term, "fontFamily", font) + scheduleFit() + }) + + let zoom = platform.webviewZoom?.() + createEffect(() => { + const next = platform.webviewZoom?.() + if (next === undefined) return + if (next === zoom) return + zoom = next + scheduleFit() + }) + + const focusTerminal = () => { + const t = term + if (!t) return + t.focus() + t.textarea?.focus() + setTimeout(() => t.textarea?.focus(), 0) + } + const handlePointerDown = () => { + const activeElement = document.activeElement + if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) { + activeElement.blur() + } + focusTerminal() + } + + const handleLinkClick = (event: MouseEvent) => { + if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return + if (event.altKey) return + if (event.button !== 0) return + + const t = term + if (!t) return + + const text = getHoveredLinkText(t) + if (!text) return + + event.preventDefault() + event.stopImmediatePropagation() + platform.openLink(text) + } + + onMount(() => { + const run = async () => { + const loaded = await loadGhostty() + if (disposed) return + + const mod = loaded.mod + const g = loaded.ghostty + + const t = new mod.Terminal({ + cursorBlink: true, + cursorStyle: "bar", + cols: restoreSize?.cols, + rows: restoreSize?.rows, + fontSize: 14, + fontFamily: monoFontFamily(settings.appearance.font()), + allowTransparency: false, + convertEol: false, + theme: terminalColors(), + scrollback: 10_000, + ghostty: g, + }) + cleanups.push(() => t.dispose()) + if (disposed) { + cleanup() + return + } + ghostty = g + term = t + output = terminalWriter((data, done) => t.write(data, done)) + + t.attachCustomKeyEventHandler((event) => { + const key = event.key.toLowerCase() + + if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") { + document.execCommand("copy") + return true + } + + // allow for toggle terminal keybinds in parent + const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND + const keybinds = parseKeybind(config) + + return matchKeybind(keybinds, event) + }) + + const fit = new mod.FitAddon() + const serializer = new SerializeAddon() + cleanups.push(() => disposeIfDisposable(fit)) + t.loadAddon(serializer) + t.loadAddon(fit) + fitAddon = fit + serializeAddon = serializer + + t.open(container) + useTerminalUiBindings({ + container, + term: t, + cleanups, + handlePointerDown, + handleLinkClick, + }) + + focusTerminal() + + if (typeof document !== "undefined" && document.fonts) { + document.fonts.ready.then(scheduleFit) + } + + const onResize = t.onResize((size) => { + scheduleSize(size.cols, size.rows) + }) + cleanups.push(() => disposeIfDisposable(onResize)) + const onData = t.onData((data) => { + if (ws?.readyState === WebSocket.OPEN) ws.send(data) + }) + cleanups.push(() => disposeIfDisposable(onData)) + const onKey = t.onKey((key) => { + if (key.key == "Enter") { + props.onSubmit?.() + } + }) + cleanups.push(() => disposeIfDisposable(onKey)) + + const startResize = () => { + fit.observeResize() + handleResize = scheduleFit + window.addEventListener("resize", handleResize) + cleanups.push(() => window.removeEventListener("resize", handleResize)) + } + + const write = (data: string) => + new Promise((resolve) => { + if (!output) { + resolve() + return + } + output.push(data) + output.flush(resolve) + }) + + if (restore && restoreSize) { + await write(restore) + fit.fit() + scheduleSize(t.cols, t.rows) + if (scrollY !== undefined) t.scrollToLine(scrollY) + startResize() + } else { + fit.fit() + scheduleSize(t.cols, t.rows) + if (restore) { + await write(restore) + if (scrollY !== undefined) t.scrollToLine(scrollY) + } + startResize() + } + + // t.onScroll((ydisp) => { + // console.log("Scroll position:", ydisp) + // }) + + const once = { value: false } + let closing = false + + const url = new URL(sdk.url + `/pty/${id}/connect`) + url.searchParams.set("directory", sdk.directory) + url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0)) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + url.username = server.current?.http.username ?? "" + url.password = server.current?.http.password ?? "" + + const socket = new WebSocket(url) + socket.binaryType = "arraybuffer" + ws = socket + + const handleOpen = () => { + local.onConnect?.() + scheduleSize(t.cols, t.rows) + } + socket.addEventListener("open", handleOpen) + if (socket.readyState === WebSocket.OPEN) handleOpen() + + const decoder = new TextDecoder() + const handleMessage = (event: MessageEvent) => { + if (disposed) return + if (closing) return + if (event.data instanceof ArrayBuffer) { + const bytes = new Uint8Array(event.data) + if (bytes[0] !== 0) return + const json = decoder.decode(bytes.subarray(1)) + try { + const meta = JSON.parse(json) as { cursor?: unknown } + const next = meta?.cursor + if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { + cursor = next + } + } catch (err) { + debugTerminal("invalid websocket control frame", err) + } + return + } + + const data = typeof event.data === "string" ? event.data : "" + if (!data) return + output?.push(data) + cursor += data.length + } + socket.addEventListener("message", handleMessage) + + const handleError = (error: Event) => { + if (disposed) return + if (closing) return + if (once.value) return + once.value = true + console.error("WebSocket error:", error) + local.onConnectError?.(error) + } + socket.addEventListener("error", handleError) + + const handleClose = (event: CloseEvent) => { + if (disposed) return + if (closing) return + // Normal closure (code 1000) means PTY process exited - server event handles cleanup + // For other codes (network issues, server restart), trigger error handler + if (event.code !== 1000) { + if (once.value) return + once.value = true + local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`)) + } + } + socket.addEventListener("close", handleClose) + + cleanups.push(() => { + closing = true + socket.removeEventListener("open", handleOpen) + socket.removeEventListener("message", handleMessage) + socket.removeEventListener("error", handleError) + socket.removeEventListener("close", handleClose) + if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000) + }) + } + + void run().catch((err) => { + if (disposed) return + showToast({ + variant: "error", + title: language.t("terminal.connectionLost.title"), + description: err instanceof Error ? err.message : language.t("terminal.connectionLost.description"), + }) + local.onConnectError?.(err) + }) + }) + + onCleanup(() => { + disposed = true + if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) + if (sizeTimer !== undefined) clearTimeout(sizeTimer) + if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) + + const finalize = () => { + persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup }) + cleanup() + } + + if (!output) { + finalize() + return + } + + output.flush(finalize) + }) + + return ( +
+ ) +} diff --git a/Agent-backend/packages/app/src/components/titlebar-history.test.ts b/Agent-backend/packages/app/src/components/titlebar-history.test.ts new file mode 100644 index 000000000..25035d7cc --- /dev/null +++ b/Agent-backend/packages/app/src/components/titlebar-history.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history" + +function history(): TitlebarHistory { + return { stack: [], index: 0, action: undefined } +} + +describe("titlebar history", () => { + test("append and trim keeps max bounded", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.stack.length).toBe(3) + expect(state.index).toBe(2) + }) + + test("back and forward indexes stay correct after trimming", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.index).toBe(2) + + const back = backPath(state) + expect(back?.to).toBe("/b") + expect(back?.state.index).toBe(1) + + const afterBack = applyPath(back!.state, back!.to, 3) + expect(afterBack.stack).toEqual(["/a", "/b", "/c"]) + expect(afterBack.index).toBe(1) + + const forward = forwardPath(afterBack) + expect(forward?.to).toBe("/c") + expect(forward?.state.index).toBe(2) + + const afterForward = applyPath(forward!.state, forward!.to, 3) + expect(afterForward.stack).toEqual(["/a", "/b", "/c"]) + expect(afterForward.index).toBe(2) + }) + + test("action-driven navigation does not push duplicate history entries", () => { + const state: TitlebarHistory = { + stack: ["/", "/a", "/b"], + index: 2, + action: undefined, + } + + const back = backPath(state) + expect(back?.to).toBe("/a") + + const next = applyPath(back!.state, back!.to, 10) + expect(next.stack).toEqual(["/", "/a", "/b"]) + expect(next.index).toBe(1) + expect(next.action).toBeUndefined() + }) +}) diff --git a/Agent-backend/packages/app/src/components/titlebar-history.ts b/Agent-backend/packages/app/src/components/titlebar-history.ts new file mode 100644 index 000000000..44dbbfa3a --- /dev/null +++ b/Agent-backend/packages/app/src/components/titlebar-history.ts @@ -0,0 +1,57 @@ +export const MAX_TITLEBAR_HISTORY = 100 + +export type TitlebarAction = "back" | "forward" | undefined + +export type TitlebarHistory = { + stack: string[] + index: number + action: TitlebarAction +} + +export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + if (!state.stack.length) { + const stack = current === "/" ? ["/"] : ["/", current] + return { stack, index: stack.length - 1, action: undefined } + } + + const active = state.stack[state.index] + if (current === active) { + if (!state.action) return state + return { ...state, action: undefined } + } + + if (state.action) return { ...state, action: undefined } + + return pushPath(state, current, max) +} + +export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + const stack = state.stack.slice(0, state.index + 1).concat(path) + const next = trimHistory(stack, stack.length - 1, max) + return { ...state, ...next, action: undefined } +} + +export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) { + if (stack.length <= max) return { stack, index } + const cut = stack.length - max + return { + stack: stack.slice(cut), + index: Math.max(0, index - cut), + } +} + +export function backPath(state: TitlebarHistory) { + if (state.index <= 0) return + const index = state.index - 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "back" as const }, to } +} + +export function forwardPath(state: TitlebarHistory) { + if (state.index >= state.stack.length - 1) return + const index = state.index + 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "forward" as const }, to } +} diff --git a/Agent-backend/packages/app/src/components/titlebar.tsx b/Agent-backend/packages/app/src/components/titlebar.tsx new file mode 100644 index 000000000..b45f81150 --- /dev/null +++ b/Agent-backend/packages/app/src/components/titlebar.tsx @@ -0,0 +1,291 @@ +import { createEffect, createMemo, Show, untrack } from "solid-js" +import { createStore } from "solid-js/store" +import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Icon } from "@opencode-ai/ui/icon" +import { Button } from "@opencode-ai/ui/button" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { useTheme } from "@opencode-ai/ui/theme" + +import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { applyPath, backPath, forwardPath } from "./titlebar-history" + +type TauriDesktopWindow = { + startDragging?: () => Promise + toggleMaximize?: () => Promise +} + +type TauriThemeWindow = { + setTheme?: (theme?: "light" | "dark" | null) => Promise +} + +type TauriApi = { + window?: { + getCurrentWindow?: () => TauriDesktopWindow + } + webviewWindow?: { + getCurrentWebviewWindow?: () => TauriThemeWindow + } +} + +const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ +const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() +const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() + +export function Titlebar() { + const layout = useLayout() + const platform = usePlatform() + const command = useCommand() + const language = useLanguage() + const theme = useTheme() + const navigate = useNavigate() + const location = useLocation() + const params = useParams() + + const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") + const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") + const web = createMemo(() => platform.platform === "web") + const zoom = () => platform.webviewZoom?.() ?? 1 + const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined) + + const [history, setHistory] = createStore({ + stack: [] as string[], + index: 0, + action: undefined as "back" | "forward" | undefined, + }) + + const path = () => `${location.pathname}${location.search}${location.hash}` + + createEffect(() => { + const current = path() + + untrack(() => { + const next = applyPath(history, current) + if (next === history) return + setHistory(next) + }) + }) + + const canBack = createMemo(() => history.index > 0) + const canForward = createMemo(() => history.index < history.stack.length - 1) + + const back = () => { + const next = backPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) + } + + const forward = () => { + const next = forwardPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) + } + + command.register(() => [ + { + id: "common.goBack", + title: language.t("common.goBack"), + category: language.t("command.category.view"), + keybind: "mod+[", + onSelect: back, + }, + { + id: "common.goForward", + title: language.t("common.goForward"), + category: language.t("command.category.view"), + keybind: "mod+]", + onSelect: forward, + }, + ]) + + const getWin = () => { + if (platform.platform !== "desktop") return + return currentDesktopWindow() + } + + createEffect(() => { + if (platform.platform !== "desktop") return + + const scheme = theme.colorScheme() + const value = scheme === "system" ? null : scheme + + const win = currentThemeWindow() + if (!win?.setTheme) return + + void win.setTheme(value).catch(() => undefined) + }) + + const interactive = (target: EventTarget | null) => { + if (!(target instanceof Element)) return false + + const selector = + "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" + + return !!target.closest(selector) + } + + const drag = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (e.buttons !== 1) return + if (interactive(e.target)) return + + const win = getWin() + if (!win?.startDragging) return + + e.preventDefault() + void win.startDragging().catch(() => undefined) + } + + const maximize = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (interactive(e.target)) return + if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return + + const win = getWin() + if (!win?.toggleMaximize) return + + e.preventDefault() + void win.toggleMaximize().catch(() => undefined) + } + + return ( +
+
+ +
+
+ +
+ + +
+ +
+
+
+ + + + +
+
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/context/CLAUDE.md b/Agent-backend/packages/app/src/context/CLAUDE.md new file mode 100644 index 000000000..621ac52b3 --- /dev/null +++ b/Agent-backend/packages/app/src/context/CLAUDE.md @@ -0,0 +1,222 @@ +# context + +> **Path:** `Agent-backend/packages/app/src/context/` + +## Purpose + +SolidJS context providers — the reactive state layer for the entire OpenCode web app. Each major domain (SDK, sync, files, terminal, settings, permissions, commands, layout, etc.) has a dedicated context provider that manages its state, side effects, and API calls. These providers are composed in a strict order in `app.tsx` (15+ nested providers). Two subdirectories (`file/`, `global-sync/`) extract complex logic into focused modules. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | The provider architecture is the primary reference for Orbit's Zustand store design. Each context maps roughly to a Zustand store slice. The SDK/sync/global-sync data flow pattern (SSE events → store updates → reactive UI) is directly relevant. | +| Orbit CLI | `reference` | Same | + +## When to Reference This + +| If you're building... | Read these files | What you'll learn | +| ---------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **SDK client setup** for connecting to the OpenCode server | `global-sdk.tsx`, `sdk.tsx` | How to create per-workspace SDK clients, set up SSE event bus, dispatch events to directory-specific listeners | +| **Real-time state sync** from server events | `global-sync.tsx`, `global-sync/event-reducer.ts` | SSE event → store mutation for 13 event types (session, message, part, permission, todo, MCP, LSP, VCS, config, project updates) with binary search sorted inserts | +| **App bootstrap / initial data loading** | `global-sync/bootstrap.ts`, `global-sync/session-load.ts` | How to fetch initial state (projects, providers, config, sessions, agents, commands, MCP, LSP, VCS) and paginated session lists | +| **Per-session message state** with optimistic updates | `sync.tsx` | Messages, parts, status, permissions, diffs, todos, questions — with optimistic add/remove and rollback | +| **Server connection management** and mDNS discovery | `server.tsx` | Active server URL tracking, connection type, mDNS discovery list, URL normalization | +| **Settings schema and persistence** | `settings.tsx` | Full `Settings` interface (general, updates, appearance, keybinds, permissions, notifications, sounds) with localStorage persistence | +| **Permission system UI** (accept once/always/reject) | `permission.tsx`, `permission-auto-respond.ts` | Pending permission tracking per session, auto-accept logic with localStorage keys | +| **Panel layout state** (sidebar, terminal, tabs) | `layout.tsx`, `layout-scroll.ts` | Default panel widths (sidebar=344, session=600, terminal=280), tab management, scroll position persistence | +| **Command palette + keybindings** | `command.tsx` | Keybind string parsing (`mod+shift+p`), Mac vs non-Mac modifier mapping, signature-based lookup | +| **Model picker + thinking level cycling** | `models.tsx`, `model-variant.ts` | Active model per directory, hidden models, variant rotation logic | +| **File tree + content caching** | `file.tsx`, `file/tree-store.ts`, `file/content-cache.ts` | File tree expand/collapse, LRU content cache with byte accounting, file watcher invalidation | +| **Platform abstraction** (web vs desktop) | `platform.tsx` | `Platform` type with `notify`, `openLink`, `back`, `forward`, `restart`, `getDefaultServerUrl` — the boundary Orbit's Tauri bridge replaces | +| **Session cache eviction** at scale | `global-sync/session-cache.ts`, `global-sync/child-store.ts`, `global-sync/eviction.ts` | LRU eviction for 50+ sessions, directory store eviction for 30+ workspaces with idle TTL | +| **Notification triggers** (agent complete, errors) | `notification.tsx`, `notification-index.ts` | When and how to fire browser notifications via platform abstraction | +| **Code review comments** | `comments.tsx` | Comment thread state on file selections | + +## Key Data Patterns (portable to React/Zustand) + +### Two-tier state architecture + +``` +GlobalSync (all projects, all sessions, all config) → Orbit: global app store + └── Sync (per-session messages, parts, status) → Orbit: ChatStore +``` + +Read `global-sync.tsx` + `sync.tsx` to understand this split. + +### SSE event reduction pattern + +`global-sync/event-reducer.ts` is the single most relevant file for Orbit's `chat-message-service.ts`. It handles 13 event types with binary search sorted inserts — the same pattern Orbit uses for Tauri events. The reducer is mostly framework-agnostic logic. + +### Optimistic updates + +`sync.tsx` shows: user sends message → `applyOptimisticAdd()` inserts immediately → server confirms or `applyOptimisticRemove()` rolls back. Uses `Binary.search()` for sorted position. + +### Settings interface (directly reusable) + +`settings.tsx` defines the full `Settings` shape — theme, font, update channel, keybinds, permission rules, notification preferences, sound toggles. The interface structure maps to Orbit's settings store. + +### Constants worth knowing + +| Constant | Value | File | +| ------------------------------- | ------ | ------------------------------ | +| `DEFAULT_PANEL_WIDTH` (sidebar) | 344px | `layout.tsx` | +| `DEFAULT_SESSION_WIDTH` | 600px | `layout.tsx` | +| `DEFAULT_TERMINAL_HEIGHT` | 280px | `layout.tsx` | +| `MAX_DIR_STORES` | 30 | `global-sync/types.ts` | +| `DIR_IDLE_TTL_MS` | 20 min | `global-sync/types.ts` | +| `SESSION_RECENT_LIMIT` | 50 | `global-sync/types.ts` | +| `SESSION_CACHE_LIMIT` | 50 | `global-sync/session-cache.ts` | + +## Architecture + +``` +GlobalSDK ─→ GlobalSync ─→ SDK ─→ Sync + │ │ │ │ + │ │ │ └─ Per-session messages, parts, optimistic updates + │ │ └─ Per-directory SDK client + event emitter + │ └─ Global state: projects, sessions, config, providers, MCP, LSP + └─ Root SDK client factory, SSE event bus, multi-directory support + +Settings, Layout, Terminal, Command, Permission, File, Prompt, etc. + └─ Independent providers consuming GlobalSync/SDK for their domain +``` + +### Provider Nesting Order (from `app.tsx`) + +``` +Platform → Language → I18n → Theme → Font → Meta → Dialog → File → +Server → SDK → Settings → GlobalSDK → GlobalSync → Layout → Models → +Permission → Notification → Command → Comments → Highlights → Prompt → +Terminal → Router +``` + +**Order matters** — each provider may depend on providers above it in the chain. + +## File Inventory + +### Core Data Flow (8 files) + +| File | Purpose | Tests | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| `global-sdk.tsx` | Root SDK client factory. Creates `OpencodeClient` instances per directory. Manages the SSE event bus that dispatches events to directory-specific listeners. All other SDK/sync providers depend on this. | | +| `global-sync.tsx` | Global state store — projects, sessions, config, providers, MCP status, LSP status, VCS info. Bootstraps on mount (fetches initial state), then applies incremental SSE events. Manages per-directory child stores with eviction (max 30 dirs, 20min idle TTL). Uses `GlobalStore` type. | `global-sync.test.ts` | +| `sdk.tsx` | Per-directory SDK client wrapper. Provides `client` (for API calls) and `event` (emitter for SSE events filtered to this directory). Uses `createSimpleContext` pattern. | | +| `sync.tsx` | Per-session reactive state. Provides `messages`, `parts`, `status`, `permissions`, `diffs`, `todos`, and `questions` for the active session. Includes optimistic update support (`applyOptimisticAdd/Remove`). Uses binary search for sorted inserts. Session cache eviction (default 50 sessions). | `sync-optimistic.test.ts` | +| `server.tsx` | Server connection management. Tracks active server URL, connection type (`http`), mDNS discovery list. `ServerConnection` namespace with `Key`, `Any`, and URL normalization. | | +| `platform.tsx` | Platform abstraction — defines `Platform` type with `notify`, `openLink`, `back`, `forward`, `restart`, `getDefaultServerUrl`. Web implementation uses native `Notification`, `window.open`, `history.back/forward`. Desktop overrides via Tauri. | | +| `local.tsx` | Local-only session state (not synced to server) — sidebar collapse state, pending review items. | | +| `prompt.tsx` | Prompt input state — content parts (text, image, agent, file attachments), `@`-mention tracking, prompt equality comparison, default prompt template. | | + +### UI State Providers (8 files) + +| File | Purpose | Tests | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `settings.tsx` | Settings persistence via `settings.v3` localStorage key. Defines `Settings` interface (general, updates, appearance, keybinds, permissions, notifications, sounds). Default values for all fields. Uses `persisted()` utility. | | +| `layout.tsx` | Panel layout state — sidebar width (`DEFAULT_PANEL_WIDTH = 344`), session width (`600`), terminal height (`280`), session tabs (active + all), session view state (scroll position, review panel, pending message). Avatar color system (6 colors). | `layout.test.ts` | +| `layout-scroll.ts` | Scroll position persistence — saves/restores scroll position per session using `requestAnimationFrame` for smooth restoration. | `layout-scroll.test.ts` | +| `command.tsx` | Command palette + keybinding system. Parses keybind strings (`mod+shift+p`), builds signature-based lookup map, handles Mac vs non-Mac modifier mapping (`mod` → `metaKey` on Mac, `ctrlKey` elsewhere). Editable keybinds for terminal and file-attach actions. | `command.test.ts`, `command-keybind.test.ts` | +| `models.tsx` | Model selection and visibility. Tracks active model per directory, hidden models list, model variants (thinking levels). | | +| `model-variant.ts` | Model variant cycling logic — rotates through provider-specific variants (e.g., Claude thinking levels). | `model-variant.test.ts` | +| `language.tsx` | i18n context — locale selection, dictionary loading via `@solid-primitives/i18n`. | | +| `highlights.tsx` | Syntax highlighting via Shiki — lazy-loads highlighter, caches themes, provides `highlight()` function for code blocks. | | + +### Permission & Notification (4 files) + +| File | Purpose | Tests | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `permission.tsx` | Permission request handling — tracks pending permissions per session, provides `respond()` function (once/always/reject). Checks config for permission prompt rules. | | +| `permission-auto-respond.ts` | Auto-accept logic — localStorage-based accept keys per directory, `autoRespondsPermission()` checks if a permission should be auto-approved. | `permission-auto-respond.test.ts` | +| `notification.tsx` | Browser notification triggers — fires on agent completion, permission requests, errors. Uses `Notification` API via platform abstraction. | `notification.test.ts` | +| `notification-index.ts` | Notification event index/tracking. | | + +### Comments (1 file) + +| File | Purpose | Tests | +| -------------- | --------------------------------------------------------------------------------------- | ------------------ | +| `comments.tsx` | Code review comment state — tracks inline comments on file selections, comment threads. | `comments.test.ts` | + +### Subdirectory: `file/` (6 files) + +File system state management — tree, content caching, view state, file watching. + +| File | Purpose | Tests | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `tree-store.ts` | File tree state — expand/collapse nodes, builds tree from flat file list. | | +| `content-cache.ts` | LRU content cache with byte-level accounting — tracks total bytes loaded, evicts least-recently-used entries when approaching memory limits. Uses `approxBytes()` for string byte estimation. | (tested via `file-content-eviction-accounting.test.ts` at parent level) | +| `view-cache.ts` | Per-file view state cache — scroll position, cursor position, tab association. | | +| `watcher.ts` | File change watcher — invalidates content cache entries when files change on disk (via SSE events). | `watcher.test.ts` | +| `path.ts` | Path helper utilities — `relative()`, `isChild()`, `dirname()`, `basename()` for file tree navigation. | `path.test.ts` | +| `types.ts` | Shared types — `FileState`, `FileSelection`, `SelectedLineRange`, `FileViewState`, `selectionFromLines()`. | | + +### Subdirectory: `global-sync/` (10 files) + +Complex logic extracted from `global-sync.tsx` — per-directory child store management, SSE event reduction, session loading, eviction, and caching. + +| File | Purpose | Tests | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| `types.ts` | Core types — `State` (per-directory store shape with agents, commands, sessions, messages, parts, permissions, MCP, LSP, VCS), `ProjectMeta`, `EvictPlan`, `ChildOptions`. Constants: `MAX_DIR_STORES=30`, `DIR_IDLE_TTL_MS=20min`, `SESSION_RECENT_LIMIT=50`. | | +| `child-store.ts` | Per-directory store lifecycle — creates/pins/unpins/evicts SolidJS stores per workspace directory. Manages VCS, meta, and icon caches per directory. Eviction runs when new stores exceed `MAX_DIR_STORES`. | `child-store.test.ts` | +| `event-reducer.ts` | SSE event → store mutation. Handles `session.updated`, `session.deleted`, `message.updated`, `part.updated`, `permission.updated`, `todo.updated`, `mcp.updated`, `lsp.updated`, `vcs.updated`, `config.updated`, `project.updated`, `global.disposed`, `server.connected`. Binary search for sorted inserts/updates. | `event-reducer.test.ts` | +| `bootstrap.ts` | Initial data fetch — `bootstrapGlobal()` fetches projects + providers + config. `bootstrapDirectory()` fetches sessions + agents + commands + MCP + LSP + VCS for a workspace. | | +| `session-load.ts` | Root session loading with fallback — estimates total session count, loads paginated session list, handles API failures gracefully. | | +| `session-trim.ts` | Session list trimming — keeps recent sessions (within 4h window) + limits total to `SESSION_RECENT_LIMIT`. | `session-trim.test.ts` | +| `session-cache.ts` | Session cache LRU — evicts message/part data for sessions beyond `SESSION_CACHE_LIMIT` (default 50). | `session-cache.test.ts` | +| `eviction.ts` | Directory store eviction — `pickDirectoriesToEvict()` selects idle stores beyond max count, `canDisposeDirectory()` checks if safe to evict. | | +| `queue.ts` | Refresh queue — debounced fetch queue for re-bootstrapping directories after reconnection events. | | +| `utils.ts` | Utility — `sanitizeProject()` cleans project data from API responses. | | + +## Test Coverage + +| Test File | Covers | +| --------------------------------------------- | -------------------------------------------------------- | +| `command.test.ts` + `command-keybind.test.ts` | Keybinding parsing, signature generation, Mac vs non-Mac | +| `comments.test.ts` | Comment thread state | +| `file-content-eviction-accounting.test.ts` | Content cache byte accounting and eviction | +| `file/path.test.ts` | Path utilities | +| `file/watcher.test.ts` | File change invalidation | +| `global-sync.test.ts` | Global sync bootstrap and event handling | +| `global-sync/child-store.test.ts` | Directory store lifecycle and eviction | +| `global-sync/event-reducer.test.ts` | SSE event → state mutation | +| `global-sync/session-cache.test.ts` | Session cache LRU eviction | +| `global-sync/session-trim.test.ts` | Session list trimming | +| `layout.test.ts` | Panel dimensions and tab state | +| `layout-scroll.test.ts` | Scroll position persistence | +| `model-variant.test.ts` | Model variant cycling | +| `notification.test.ts` | Notification triggers | +| `permission-auto-respond.test.ts` | Auto-accept logic | +| `sync-optimistic.test.ts` | Optimistic add/remove | + +## Dependencies + +- **SDK:** `@opencode-ai/sdk/v2/client` for all data types (`Session`, `Message`, `Part`, `Config`, `Project`, `PermissionRequest`, `Todo`, `FileDiff`, `McpStatus`, `LspStatus`, etc.) +- **UI library:** `@opencode-ai/ui/context` (`createSimpleContext`), `@opencode-ai/ui/toast` (`showToast`) +- **Utilities:** `@opencode-ai/util/binary` (sorted array operations), `@opencode-ai/util/retry`, `@opencode-ai/util/path`, `@opencode-ai/util/encode` +- **Internal:** `@/utils/persist` (localStorage wrapper), `@/utils/base64`, `@/utils/same` (deep equality), `@/utils/server-health`, `@/utils/server-errors` +- **SolidJS:** `solid-js`, `solid-js/store` (`createStore`, `produce`, `reconcile`), `@solidjs/router`, `@solid-primitives/i18n`, `@solid-primitives/storage`, `@solid-primitives/event-bus` + +## Recommendation + +**KEEP** — This is the most valuable reference layer in `packages/app/`. It shows exactly how the upstream app wires up SDK data fetching, SSE event processing, session state, and optimistic updates. Orbit's Zustand stores are direct analogs. The 3 pure-logic files in `global-sync/` (event-reducer, session-cache, session-trim) are near-portable to React. 19 test files make this the most thoroughly tested frontend layer — patterns worth studying before writing Orbit equivalents. + +## Orbit Mapping + +| OpenCode context | Orbit equivalent | Status | +| ---------------------------------- | ------------------------------------------- | ------------------------- | +| `global-sync.tsx` + `global-sync/` | Global app store (TBD) | Reference for future work | +| `sync.tsx` | `chat-store.ts` + `chat-message-service.ts` | Already implemented | +| `settings.tsx` | `settings-store.ts` | Already implemented | +| `layout.tsx` | `ui-store.ts` | Already implemented | +| `permission.tsx` | Permission handling in agent-bridge | Already implemented | +| `file.tsx` + `file/` | `file-store.ts` | Already implemented | +| `platform.tsx` | Tauri bridge (`src-tauri/`) | Already implemented | +| `command.tsx` | Command palette (TBD) | Not yet built | +| `models.tsx` | Model picker in agent-bridge | Partially implemented | + +## Notes + +- **`createSimpleContext` pattern** — most providers use `@opencode-ai/ui/context`'s `createSimpleContext`, which auto-generates `useX()` hook and `XProvider` component from an `init()` function. Orbit's Zustand stores serve the same purpose without the SolidJS context boilerplate. +- **Binary search everywhere** — sorted arrays + `Binary.search()` from `@opencode-ai/util/binary` is the canonical pattern for sessions, messages, and parts. All stores maintain sorted order by ID. +- **Multi-directory support** — `GlobalSync` manages state for multiple workspace directories simultaneously (up to 30), with idle eviction after 20 minutes. This is more complex than Orbit currently needs but shows how to scale. +- **19 test files** across context + subdirectories — the most thoroughly tested layer of the frontend. +- **`reconcile()` for state updates** — SolidJS's `reconcile()` (like React's reconciliation) is used to efficiently update store arrays from server responses. Orbit uses Immer's `produce()` instead. diff --git a/Agent-backend/packages/app/src/context/command-keybind.test.ts b/Agent-backend/packages/app/src/context/command-keybind.test.ts new file mode 100644 index 000000000..4e38efd8d --- /dev/null +++ b/Agent-backend/packages/app/src/context/command-keybind.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { formatKeybind, matchKeybind, parseKeybind } from "./command" + +describe("command keybind helpers", () => { + test("parseKeybind handles aliases and multiple combos", () => { + const keybinds = parseKeybind("control+option+k, mod+shift+comma") + + expect(keybinds).toHaveLength(2) + expect(keybinds[0]).toEqual({ + key: "k", + ctrl: true, + meta: false, + shift: false, + alt: true, + }) + expect(keybinds[1]?.shift).toBe(true) + expect(keybinds[1]?.key).toBe("comma") + expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true) + }) + + test("parseKeybind treats none and empty as disabled", () => { + expect(parseKeybind("none")).toEqual([]) + expect(parseKeybind("")).toEqual([]) + }) + + test("matchKeybind normalizes punctuation keys", () => { + const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space") + + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true) + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true) + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true) + expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false) + }) + + test("formatKeybind returns human readable output", () => { + const display = formatKeybind("ctrl+alt+arrowup") + + expect(display).toContain("↑") + expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true) + expect(display.includes("Alt") || display.includes("⌥")).toBe(true) + expect(formatKeybind("none")).toBe("") + }) +}) diff --git a/Agent-backend/packages/app/src/context/command.test.ts b/Agent-backend/packages/app/src/context/command.test.ts new file mode 100644 index 000000000..2b956287c --- /dev/null +++ b/Agent-backend/packages/app/src/context/command.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { upsertCommandRegistration } from "./command" + +describe("upsertCommandRegistration", () => { + test("replaces keyed registrations", () => { + const one = () => [{ id: "one", title: "One" }] + const two = () => [{ id: "two", title: "Two" }] + + const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two }) + + expect(next).toHaveLength(1) + expect(next[0]?.options).toBe(two) + }) + + test("keeps unkeyed registrations additive", () => { + const one = () => [{ id: "one", title: "One" }] + const two = () => [{ id: "two", title: "Two" }] + + const next = upsertCommandRegistration([{ options: one }], { options: two }) + + expect(next).toHaveLength(2) + expect(next[0]?.options).toBe(two) + expect(next[1]?.options).toBe(one) + }) +}) diff --git a/Agent-backend/packages/app/src/context/command.tsx b/Agent-backend/packages/app/src/context/command.tsx new file mode 100644 index 000000000..03bd6318d --- /dev/null +++ b/Agent-backend/packages/app/src/context/command.tsx @@ -0,0 +1,392 @@ +import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" +import { Persist, persisted } from "@/utils/persist" + +const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + +const PALETTE_ID = "command.palette" +const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" +const SUGGESTED_PREFIX = "suggested." +const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) + +function actionId(id: string) { + if (!id.startsWith(SUGGESTED_PREFIX)) return id + return id.slice(SUGGESTED_PREFIX.length) +} + +function normalizeKey(key: string) { + if (key === ",") return "comma" + if (key === "+") return "plus" + if (key === " ") return "space" + return key.toLowerCase() +} + +function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) { + const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0) + return `${key}:${mask}` +} + +function signatureFromEvent(event: KeyboardEvent) { + return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey) +} + +function isAllowedEditableKeybind(id: string | undefined) { + if (!id) return false + return EDITABLE_KEYBIND_IDS.has(actionId(id)) +} + +export type KeybindConfig = string + +export interface Keybind { + key: string + ctrl: boolean + meta: boolean + shift: boolean + alt: boolean +} + +export interface CommandOption { + id: string + title: string + description?: string + category?: string + keybind?: KeybindConfig + slash?: string + suggested?: boolean + disabled?: boolean + onSelect?: (source?: "palette" | "keybind" | "slash") => void + onHighlight?: () => (() => void) | void +} + +type CommandSource = "palette" | "keybind" | "slash" + +export type CommandCatalogItem = { + title: string + description?: string + category?: string + keybind?: KeybindConfig + slash?: string +} + +export type CommandRegistration = { + key?: string + options: Accessor +} + +export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { + if (entry.key === undefined) return [entry, ...registrations] + return [entry, ...registrations.filter((x) => x.key !== entry.key)] +} + +export function parseKeybind(config: string): Keybind[] { + if (!config || config === "none") return [] + + return config.split(",").map((combo) => { + const parts = combo.trim().toLowerCase().split("+") + const keybind: Keybind = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of parts) { + switch (part) { + case "ctrl": + case "control": + keybind.ctrl = true + break + case "meta": + case "cmd": + case "command": + keybind.meta = true + break + case "mod": + if (IS_MAC) keybind.meta = true + else keybind.ctrl = true + break + case "alt": + case "option": + keybind.alt = true + break + case "shift": + keybind.shift = true + break + default: + keybind.key = part + break + } + } + + return keybind + }) +} + +export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { + const eventKey = normalizeKey(event.key) + + for (const kb of keybinds) { + const keyMatch = kb.key === eventKey + const ctrlMatch = kb.ctrl === (event.ctrlKey || false) + const metaMatch = kb.meta === (event.metaKey || false) + const shiftMatch = kb.shift === (event.shiftKey || false) + const altMatch = kb.alt === (event.altKey || false) + + if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) { + return true + } + } + + return false +} + +export function formatKeybind(config: string): string { + if (!config || config === "none") return "" + + const keybinds = parseKeybind(config) + if (keybinds.length === 0) return "" + + const kb = keybinds[0] + const parts: string[] = [] + + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") + if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") + if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") + if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") + + if (kb.key) { + const keys: Record = { + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + comma: ",", + plus: "+", + space: "Space", + } + const key = kb.key.toLowerCase() + const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1)) + parts.push(displayKey) + } + + return IS_MAC ? parts.join("") : parts.join("+") +} + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false + if (target.isContentEditable) return true + if (target.closest("[contenteditable='true']")) return true + if (target.closest("input, textarea, select")) return true + return false +} + +export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ + name: "Command", + init: () => { + const dialog = useDialog() + const settings = useSettings() + const language = useLanguage() + const [store, setStore] = createStore({ + registrations: [] as CommandRegistration[], + suspendCount: 0, + }) + const warnedDuplicates = new Set() + + const [catalog, setCatalog, _, catalogReady] = persisted( + Persist.global("command.catalog.v1"), + createStore>({}), + ) + + const bind = (id: string, def: KeybindConfig | undefined) => { + const custom = settings.keybinds.get(actionId(id)) + const config = custom ?? def + if (!config || config === "none") return + return config + } + + const registered = createMemo(() => { + const seen = new Set() + const all: CommandOption[] = [] + + for (const reg of store.registrations) { + for (const opt of reg.options()) { + if (seen.has(opt.id)) { + if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { + warnedDuplicates.add(opt.id) + console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`) + } + continue + } + seen.add(opt.id) + all.push(opt) + } + } + + return all + }) + + createEffect(() => { + if (!catalogReady()) return + + for (const opt of registered()) { + const id = actionId(opt.id) + setCatalog(id, { + title: opt.title, + description: opt.description, + category: opt.category, + keybind: opt.keybind, + slash: opt.slash, + }) + } + }) + + const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta }))) + + const options = createMemo(() => { + const resolved = registered().map((opt) => ({ + ...opt, + keybind: bind(opt.id, opt.keybind), + })) + + const suggested = resolved.filter((x) => x.suggested && !x.disabled) + + return [ + ...suggested.map((x) => ({ + ...x, + id: SUGGESTED_PREFIX + x.id, + category: language.t("command.category.suggested"), + })), + ...resolved, + ] + }) + + const suspended = () => store.suspendCount > 0 + + const palette = createMemo(() => { + const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND + const keybinds = parseKeybind(config) + return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt))) + }) + + const keymap = createMemo(() => { + const map = new Map() + for (const option of options()) { + if (option.id.startsWith(SUGGESTED_PREFIX)) continue + if (option.disabled) continue + if (!option.keybind) continue + + const keybinds = parseKeybind(option.keybind) + for (const kb of keybinds) { + if (!kb.key) continue + const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt) + if (map.has(sig)) continue + map.set(sig, option) + } + } + return map + }) + + const optionMap = createMemo(() => { + const map = new Map() + for (const option of options()) { + map.set(option.id, option) + map.set(actionId(option.id), option) + } + return map + }) + + const run = (id: string, source?: CommandSource) => { + const option = optionMap().get(id) + option?.onSelect?.(source) + } + + const showPalette = () => { + run("file.open", "palette") + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (suspended() || dialog.active) return + + const sig = signatureFromEvent(event) + const isPalette = palette().has(sig) + const option = keymap().get(sig) + const modified = event.ctrlKey || event.metaKey || event.altKey + const isTab = event.key === "Tab" + + if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab) + return + + if (isPalette) { + event.preventDefault() + showPalette() + return + } + + if (!option) return + event.preventDefault() + option.onSelect?.("keybind") + } + + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + + function register(cb: () => CommandOption[]): void + function register(key: string, cb: () => CommandOption[]): void + function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) { + const id = typeof key === "string" ? key : undefined + const next = typeof key === "function" ? key : cb + if (!next) return + const options = createMemo(next) + const entry: CommandRegistration = { + key: id, + options, + } + setStore("registrations", (arr) => upsertCommandRegistration(arr, entry)) + onCleanup(() => { + setStore("registrations", (arr) => arr.filter((x) => x !== entry)) + }) + } + + return { + register, + trigger(id: string, source?: CommandSource) { + run(id, source) + }, + keybind(id: string) { + if (id === PALETTE_ID) { + return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND) + } + + const base = actionId(id) + const option = options().find((x) => actionId(x.id) === base) + if (option?.keybind) return formatKeybind(option.keybind) + + const meta = catalog[base] + const config = bind(base, meta?.keybind) + if (!config) return "" + return formatKeybind(config) + }, + show: showPalette, + keybinds(enabled: boolean) { + setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1))) + }, + suspended, + get catalog() { + return catalogOptions() + }, + get options() { + return options() + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/comments.test.ts b/Agent-backend/packages/app/src/context/comments.test.ts new file mode 100644 index 000000000..82fa170f2 --- /dev/null +++ b/Agent-backend/packages/app/src/context/comments.test.ts @@ -0,0 +1,186 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" +import { createRoot } from "solid-js" +import type { LineComment } from "./comments" + +let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, + useParams: () => ({}), + })) + mock.module("@opencode-ai/ui/context", () => ({ + createSimpleContext: () => ({ + use: () => undefined, + provider: () => undefined, + }), + })) + const mod = await import("./comments") + createCommentSessionForTest = mod.createCommentSessionForTest +}) + +function line(file: string, id: string, time: number): LineComment { + return { + id, + file, + comment: id, + time, + selection: { start: 1, end: 1 }, + } +} + +describe("comments session indexing", () => { + test("keeps file list behavior and aggregate chronological order", () => { + createRoot((dispose) => { + const now = Date.now() + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)], + "b.ts": [line("b.ts", "b-mid", now + 10_000)], + }) + + expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"]) + expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"]) + + const next = comments.add({ + file: "b.ts", + comment: "next", + selection: { start: 2, end: 2 }, + }) + + expect(comments.list("b.ts").at(-1)?.id).toBe(next.id) + expect(comments.all().map((item) => item.time)).toEqual( + comments + .all() + .map((item) => item.time) + .slice() + .sort((a, b) => a - b), + ) + + dispose() + }) + }) + + test("remove updates file and aggregate indexes consistently", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)], + "b.ts": [line("b.ts", "shared", 30)], + }) + + comments.setFocus({ file: "a.ts", id: "shared" }) + comments.setActive({ file: "a.ts", id: "shared" }) + comments.remove("a.ts", "shared") + + expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"]) + expect( + comments + .all() + .filter((item) => item.id === "shared") + .map((item) => item.file), + ).toEqual(["b.ts"]) + expect(comments.focus()).toBeNull() + expect(comments.active()).toEqual({ file: "a.ts", id: "shared" }) + + dispose() + }) + }) + + test("clear resets file and aggregate indexes plus focus state", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10)], + }) + + const next = comments.add({ + file: "b.ts", + comment: "next", + selection: { start: 2, end: 2 }, + }) + + comments.setActive({ file: "b.ts", id: next.id }) + comments.clear() + + expect(comments.list("a.ts")).toEqual([]) + expect(comments.list("b.ts")).toEqual([]) + expect(comments.all()).toEqual([]) + expect(comments.focus()).toBeNull() + expect(comments.active()).toBeNull() + + dispose() + }) + }) + + test("remove keeps focus when same comment id exists in another file", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "shared", 10)], + "b.ts": [line("b.ts", "shared", 20)], + }) + + comments.setFocus({ file: "b.ts", id: "shared" }) + comments.remove("a.ts", "shared") + + expect(comments.focus()).toEqual({ file: "b.ts", id: "shared" }) + expect(comments.list("a.ts")).toEqual([]) + expect(comments.list("b.ts").map((item) => item.id)).toEqual(["shared"]) + + dispose() + }) + }) + + test("setFocus and setActive updater callbacks receive current state", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest() + + comments.setFocus({ file: "a.ts", id: "a1" }) + comments.setFocus((current) => { + expect(current).toEqual({ file: "a.ts", id: "a1" }) + return { file: "b.ts", id: "b1" } + }) + + comments.setActive({ file: "c.ts", id: "c1" }) + comments.setActive((current) => { + expect(current).toEqual({ file: "c.ts", id: "c1" }) + return null + }) + + expect(comments.focus()).toEqual({ file: "b.ts", id: "b1" }) + expect(comments.active()).toBeNull() + + dispose() + }) + }) + + test("update changes only the targeted comment body", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)], + }) + + comments.update("a.ts", "a2", "edited") + + expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"]) + + dispose() + }) + }) + + test("replace swaps comment state and clears focus state", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10)], + }) + + comments.setFocus({ file: "a.ts", id: "a1" }) + comments.setActive({ file: "a.ts", id: "a1" }) + comments.replace([line("b.ts", "b1", 30)]) + + expect(comments.list("a.ts")).toEqual([]) + expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"]) + expect(comments.focus()).toBeNull() + expect(comments.active()).toBeNull() + + dispose() + }) + }) +}) diff --git a/Agent-backend/packages/app/src/context/comments.tsx b/Agent-backend/packages/app/src/context/comments.tsx new file mode 100644 index 000000000..a97010c0a --- /dev/null +++ b/Agent-backend/packages/app/src/context/comments.tsx @@ -0,0 +1,243 @@ +import { batch, createMemo, createRoot, onCleanup } from "solid-js" +import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useParams } from "@solidjs/router" +import { Persist, persisted } from "@/utils/persist" +import { createScopedCache } from "@/utils/scoped-cache" +import { uuid } from "@/utils/uuid" +import type { SelectedLineRange } from "@/context/file" + +export type LineComment = { + id: string + file: string + selection: SelectedLineRange + comment: string + time: number +} + +type CommentFocus = { file: string; id: string } + +const WORKSPACE_KEY = "__workspace__" +const MAX_COMMENT_SESSIONS = 20 + +function sessionKey(dir: string, id: string | undefined) { + return `${dir}\n${id ?? WORKSPACE_KEY}` +} + +function decodeSessionKey(key: string) { + const split = key.lastIndexOf("\n") + if (split < 0) return { dir: key, id: WORKSPACE_KEY } + return { + dir: key.slice(0, split), + id: key.slice(split + 1), + } +} + +type CommentStore = { + comments: Record +} + +function aggregate(comments: Record) { + return Object.keys(comments) + .flatMap((file) => comments[file] ?? []) + .slice() + .sort((a, b) => a.time - b.time) +} + +function cloneSelection(selection: SelectedLineRange): SelectedLineRange { + const next: SelectedLineRange = { + start: selection.start, + end: selection.end, + } + + if (selection.side) next.side = selection.side + if (selection.endSide) next.endSide = selection.endSide + return next +} + +function cloneComment(comment: LineComment): LineComment { + return { + ...comment, + selection: cloneSelection(comment.selection), + } +} + +function group(comments: LineComment[]) { + return comments.reduce>((acc, comment) => { + const list = acc[comment.file] + const next = cloneComment(comment) + if (list) { + list.push(next) + return acc + } + acc[comment.file] = [next] + return acc + }, {}) +} + +function createCommentSessionState(store: Store, setStore: SetStoreFunction) { + const [state, setState] = createStore({ + focus: null as CommentFocus | null, + active: null as CommentFocus | null, + }) + + const all = () => aggregate(store.comments) + + const setRef = ( + key: "focus" | "active", + value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null), + ) => setState(key, value) + + const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => + setRef("focus", value) + + const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => + setRef("active", value) + + const list = (file: string) => store.comments[file] ?? [] + + const add = (input: Omit) => { + const next: LineComment = { + id: uuid(), + time: Date.now(), + ...input, + selection: cloneSelection(input.selection), + } + + batch(() => { + setStore("comments", input.file, (items) => [...(items ?? []), next]) + setFocus({ file: input.file, id: next.id }) + }) + + return next + } + + const remove = (file: string, id: string) => { + batch(() => { + setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id)) + setFocus((current) => (current?.file === file && current.id === id ? null : current)) + }) + } + + const update = (file: string, id: string, comment: string) => { + setStore("comments", file, (items) => + (items ?? []).map((item) => { + if (item.id !== id) return item + return { ...item, comment } + }), + ) + } + + const replace = (comments: LineComment[]) => { + batch(() => { + setStore("comments", reconcile(group(comments))) + setFocus(null) + setActive(null) + }) + } + + const clear = () => { + batch(() => { + setStore("comments", reconcile({})) + setFocus(null) + setActive(null) + }) + } + + return { + list, + all, + add, + remove, + update, + replace, + clear, + focus: () => state.focus, + setFocus, + clearFocus: () => setRef("focus", null), + active: () => state.active, + setActive, + clearActive: () => setRef("active", null), + } +} + +export function createCommentSessionForTest(comments: Record = {}) { + const [store, setStore] = createStore({ comments }) + return createCommentSessionState(store, setStore) +} + +function createCommentSession(dir: string, id: string | undefined) { + const legacy = `${dir}/comments${id ? "/" + id : ""}.v1` + + const [store, setStore, _, ready] = persisted( + Persist.scoped(dir, id, "comments", [legacy]), + createStore({ + comments: {}, + }), + ) + const session = createCommentSessionState(store, setStore) + + return { + ready, + list: session.list, + all: session.all, + add: session.add, + remove: session.remove, + update: session.update, + replace: session.replace, + clear: session.clear, + focus: session.focus, + setFocus: session.setFocus, + clearFocus: session.clearFocus, + active: session.active, + setActive: session.setActive, + clearActive: session.clearActive, + } +} + +export const { use: useComments, provider: CommentsProvider } = createSimpleContext({ + name: "Comments", + gate: false, + init: () => { + const params = useParams() + const cache = createScopedCache( + (key) => { + const decoded = decodeSessionKey(key) + return createRoot((dispose) => ({ + value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id), + dispose, + })) + }, + { + maxEntries: MAX_COMMENT_SESSIONS, + dispose: (entry) => entry.dispose(), + }, + ) + + onCleanup(() => cache.clear()) + + const load = (dir: string, id: string | undefined) => { + const key = sessionKey(dir, id) + return cache.get(key).value + } + + const session = createMemo(() => load(params.dir!, params.id)) + + return { + ready: () => session().ready(), + list: (file: string) => session().list(file), + all: () => session().all(), + add: (input: Omit) => session().add(input), + remove: (file: string, id: string) => session().remove(file, id), + update: (file: string, id: string, comment: string) => session().update(file, id, comment), + replace: (comments: LineComment[]) => session().replace(comments), + clear: () => session().clear(), + focus: () => session().focus(), + setFocus: (focus: CommentFocus | null) => session().setFocus(focus), + clearFocus: () => session().clearFocus(), + active: () => session().active(), + setActive: (active: CommentFocus | null) => session().setActive(active), + clearActive: () => session().clearActive(), + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/file-content-eviction-accounting.test.ts b/Agent-backend/packages/app/src/context/file-content-eviction-accounting.test.ts new file mode 100644 index 000000000..4ef5f947c --- /dev/null +++ b/Agent-backend/packages/app/src/context/file-content-eviction-accounting.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { + evictContentLru, + getFileContentBytesTotal, + getFileContentEntryCount, + removeFileContentBytes, + resetFileContentLru, + setFileContentBytes, + touchFileContent, +} from "./file/content-cache" + +describe("file content eviction accounting", () => { + afterEach(() => { + resetFileContentLru() + }) + + test("updates byte totals incrementally for set, overwrite, remove, and reset", () => { + setFileContentBytes("a", 10) + setFileContentBytes("b", 15) + expect(getFileContentBytesTotal()).toBe(25) + expect(getFileContentEntryCount()).toBe(2) + + setFileContentBytes("a", 5) + expect(getFileContentBytesTotal()).toBe(20) + expect(getFileContentEntryCount()).toBe(2) + + touchFileContent("a") + expect(getFileContentBytesTotal()).toBe(20) + + removeFileContentBytes("b") + expect(getFileContentBytesTotal()).toBe(5) + expect(getFileContentEntryCount()).toBe(1) + + resetFileContentLru() + expect(getFileContentBytesTotal()).toBe(0) + expect(getFileContentEntryCount()).toBe(0) + }) + + test("evicts by entry cap using LRU order", () => { + for (const i of Array.from({ length: 41 }, (_, n) => n)) { + setFileContentBytes(`f-${i}`, 1) + } + + const evicted: string[] = [] + evictContentLru(undefined, (path) => evicted.push(path)) + + expect(evicted).toEqual(["f-0"]) + expect(getFileContentEntryCount()).toBe(40) + expect(getFileContentBytesTotal()).toBe(40) + }) + + test("evicts by byte cap while preserving protected entries", () => { + const chunk = 8 * 1024 * 1024 + setFileContentBytes("a", chunk) + setFileContentBytes("b", chunk) + setFileContentBytes("c", chunk) + + const evicted: string[] = [] + evictContentLru(new Set(["a"]), (path) => evicted.push(path)) + + expect(evicted).toEqual(["b"]) + expect(getFileContentEntryCount()).toBe(2) + expect(getFileContentBytesTotal()).toBe(chunk * 2) + }) +}) diff --git a/Agent-backend/packages/app/src/context/file.tsx b/Agent-backend/packages/app/src/context/file.tsx new file mode 100644 index 000000000..99c6d2e42 --- /dev/null +++ b/Agent-backend/packages/app/src/context/file.tsx @@ -0,0 +1,280 @@ +import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { showToast } from "@opencode-ai/ui/toast" +import { useParams } from "@solidjs/router" +import { getFilename } from "@opencode-ai/util/path" +import { useSDK } from "./sdk" +import { useSync } from "./sync" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { createPathHelpers } from "./file/path" +import { + approxBytes, + evictContentLru, + getFileContentBytesTotal, + getFileContentEntryCount, + hasFileContent, + removeFileContentBytes, + resetFileContentLru, + setFileContentBytes, + touchFileContent, +} from "./file/content-cache" +import { createFileViewCache } from "./file/view-cache" +import { createFileTreeStore } from "./file/tree-store" +import { invalidateFromWatcher } from "./file/watcher" +import { + selectionFromLines, + type FileState, + type FileSelection, + type FileViewState, + type SelectedLineRange, +} from "./file/types" + +export type { FileSelection, SelectedLineRange, FileViewState, FileState } +export { selectionFromLines } +export { + evictContentLru, + getFileContentBytesTotal, + getFileContentEntryCount, + removeFileContentBytes, + resetFileContentLru, + setFileContentBytes, + touchFileContent, +} + +function errorMessage(error: unknown) { + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error) return error + return "Unknown error" +} + +export const { use: useFile, provider: FileProvider } = createSimpleContext({ + name: "File", + gate: false, + init: () => { + const sdk = useSDK() + useSync() + const params = useParams() + const language = useLanguage() + const layout = useLayout() + + const scope = createMemo(() => sdk.directory) + const path = createPathHelpers(scope) + const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + + const inflight = new Map>() + const [store, setStore] = createStore<{ + file: Record + }>({ + file: {}, + }) + + const tree = createFileTreeStore({ + scope, + normalizeDir: path.normalizeDir, + list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []), + onError: (message) => { + showToast({ + variant: "error", + title: language.t("toast.file.listFailed.title"), + description: message, + }) + }, + }) + + const evictContent = (keep?: Set) => { + evictContentLru(keep, (target) => { + if (!store.file[target]) return + setStore( + "file", + target, + produce((draft) => { + draft.content = undefined + draft.loaded = false + }), + ) + }) + } + + createEffect(() => { + scope() + inflight.clear() + resetFileContentLru() + batch(() => { + setStore("file", reconcile({})) + tree.reset() + }) + }) + + const viewCache = createFileViewCache() + const view = createMemo(() => viewCache.load(scope(), params.id)) + + const ensure = (file: string) => { + if (!file) return + if (store.file[file]) return + setStore("file", file, { path: file, name: getFilename(file) }) + } + + const setLoading = (file: string) => { + setStore( + "file", + file, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + } + + const setLoaded = (file: string, content: FileState["content"]) => { + setStore( + "file", + file, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.content = content + }), + ) + } + + const setLoadError = (file: string, message: string) => { + setStore( + "file", + file, + produce((draft) => { + draft.loading = false + draft.error = message + }), + ) + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + description: message, + }) + } + + const load = (input: string, options?: { force?: boolean }) => { + const file = path.normalize(input) + if (!file) return Promise.resolve() + + const directory = scope() + const key = `${directory}\n${file}` + ensure(file) + + const current = store.file[file] + if (!options?.force && current?.loaded) return Promise.resolve() + + const pending = inflight.get(key) + if (pending) return pending + + setLoading(file) + + const promise = sdk.client.file + .read({ path: file }) + .then((x) => { + if (scope() !== directory) return + const content = x.data + setLoaded(file, content) + + if (!content) return + touchFileContent(file, approxBytes(content)) + evictContent(new Set([file])) + }) + .catch((e) => { + if (scope() !== directory) return + setLoadError(file, errorMessage(e)) + }) + .finally(() => { + inflight.delete(key) + }) + + inflight.set(key, promise) + return promise + } + + const search = (query: string, dirs: "true" | "false") => + sdk.client.find.files({ query, dirs }).then( + (x) => (x.data ?? []).map(path.normalize), + () => [], + ) + + const stop = sdk.event.listen((e) => { + invalidateFromWatcher(e.details, { + normalize: path.normalize, + hasFile: (file) => Boolean(store.file[file]), + isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file), + loadFile: (file) => { + void load(file, { force: true }) + }, + node: tree.node, + isDirLoaded: tree.isLoaded, + refreshDir: (dir) => { + void tree.listDir(dir, { force: true }) + }, + }) + }) + + const get = (input: string) => { + const file = path.normalize(input) + const state = store.file[file] + const content = state?.content + if (!content) return state + if (hasFileContent(file)) { + touchFileContent(file) + return state + } + touchFileContent(file, approxBytes(content)) + return state + } + + function withPath(input: string, action: (file: string) => unknown) { + return action(path.normalize(input)) + } + const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file)) + const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file)) + const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file)) + const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top)) + const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left)) + const setSelectedLines = (input: string, range: SelectedLineRange | null) => + withPath(input, (file) => view().setSelectedLines(file, range)) + + onCleanup(() => { + stop() + viewCache.clear() + }) + + return { + ready: () => view().ready(), + normalize: path.normalize, + tab: path.tab, + pathFromTab: path.pathFromTab, + tree: { + list: tree.listDir, + refresh: (input: string) => tree.listDir(input, { force: true }), + state: tree.dirState, + children: tree.children, + expand: tree.expandDir, + collapse: tree.collapseDir, + toggle(input: string) { + if (tree.dirState(input)?.expanded) { + tree.collapseDir(input) + return + } + tree.expandDir(input) + }, + }, + get, + load, + scrollTop, + scrollLeft, + setScrollTop, + setScrollLeft, + selectedLines, + setSelectedLines, + searchFiles: (query: string) => search(query, "false"), + searchFilesAndDirectories: (query: string) => search(query, "true"), + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/file/CLAUDE.md b/Agent-backend/packages/app/src/context/file/CLAUDE.md new file mode 100644 index 000000000..f5847a2be --- /dev/null +++ b/Agent-backend/packages/app/src/context/file/CLAUDE.md @@ -0,0 +1,16 @@ +# file + +> **Path:** `Agent-backend/packages/app/src/context/file/` + +See parent CLAUDE.md at `packages/app/CLAUDE.md` for detailed documentation of this directory. + +## Purpose + +File context submodules for the file tree and editor. Manages file path resolution and normalization, tree store state, content caching with eviction, view cache for scroll/cursor positions, and a file system watcher that syncs changes from the backend. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Content eviction strategy and tree store patterns are useful references for Orbit's file explorer | +| Orbit CLI | `reference` | Same | diff --git a/Agent-backend/packages/app/src/context/file/content-cache.ts b/Agent-backend/packages/app/src/context/file/content-cache.ts new file mode 100644 index 000000000..4b7240688 --- /dev/null +++ b/Agent-backend/packages/app/src/context/file/content-cache.ts @@ -0,0 +1,88 @@ +import type { FileContent } from "@opencode-ai/sdk/v2" + +const MAX_FILE_CONTENT_ENTRIES = 40 +const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 + +const lru = new Map() +let total = 0 + +export function approxBytes(content: FileContent) { + const patchBytes = + content.patch?.hunks.reduce((sum, hunk) => { + return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0) + }, 0) ?? 0 + + return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2 +} + +function setBytes(path: string, nextBytes: number) { + const prev = lru.get(path) + if (prev !== undefined) total -= prev + lru.delete(path) + lru.set(path, nextBytes) + total += nextBytes +} + +function touch(path: string, bytes?: number) { + const prev = lru.get(path) + if (prev === undefined && bytes === undefined) return + setBytes(path, bytes ?? prev ?? 0) +} + +function remove(path: string) { + const prev = lru.get(path) + if (prev === undefined) return + lru.delete(path) + total -= prev +} + +function reset() { + lru.clear() + total = 0 +} + +export function evictContentLru(keep: Set | undefined, evict: (path: string) => void) { + const set = keep ?? new Set() + + while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) { + const path = lru.keys().next().value + if (!path) return + + if (set.has(path)) { + touch(path) + if (lru.size <= set.size) return + continue + } + + remove(path) + evict(path) + } +} + +export function resetFileContentLru() { + reset() +} + +export function setFileContentBytes(path: string, bytes: number) { + setBytes(path, bytes) +} + +export function removeFileContentBytes(path: string) { + remove(path) +} + +export function touchFileContent(path: string, bytes?: number) { + touch(path, bytes) +} + +export function getFileContentBytesTotal() { + return total +} + +export function getFileContentEntryCount() { + return lru.size +} + +export function hasFileContent(path: string) { + return lru.has(path) +} diff --git a/Agent-backend/packages/app/src/context/file/path.test.ts b/Agent-backend/packages/app/src/context/file/path.test.ts new file mode 100644 index 000000000..feef6d466 --- /dev/null +++ b/Agent-backend/packages/app/src/context/file/path.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from "bun:test" +import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath } from "./path" + +describe("file path helpers", () => { + test("normalizes file inputs against workspace root", () => { + const path = createPathHelpers(() => "/repo") + expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts") + expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("./src/app.ts")).toBe("src/app.ts") + expect(path.normalizeDir("src/components///")).toBe("src/components") + expect(path.tab("src/app.ts")).toBe("file://src/app.ts") + expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts") + expect(path.pathFromTab("other://src/app.ts")).toBeUndefined() + }) + + test("normalizes Windows absolute paths with mixed separators", () => { + const path = createPathHelpers(() => "C:\\repo") + expect(path.normalize("C:\\repo\\src\\app.ts")).toBe("src\\app.ts") + expect(path.normalize("C:/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("file://C:/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts") + }) + + test("keeps query/hash stripping behavior stable", () => { + expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts") + }) + + test("unquotes git escaped octal path strings", () => { + expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt") + expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname") + expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts") + }) +}) + +describe("encodeFilePath", () => { + describe("Linux/Unix paths", () => { + test("should handle Linux absolute path", () => { + const linuxPath = "/home/user/project/README.md" + const result = encodeFilePath(linuxPath) + const fileUrl = `file://${result}` + + // Should create a valid URL + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/home/user/project/README.md") + + const url = new URL(fileUrl) + expect(url.protocol).toBe("file:") + expect(url.pathname).toBe("/home/user/project/README.md") + }) + + test("should handle Linux path with special characters", () => { + const linuxPath = "/home/user/file#name with spaces.txt" + const result = encodeFilePath(linuxPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/home/user/file%23name%20with%20spaces.txt") + }) + + test("should handle Linux relative path", () => { + const relativePath = "src/components/App.tsx" + const result = encodeFilePath(relativePath) + + expect(result).toBe("src/components/App.tsx") + }) + + test("should handle Linux root directory", () => { + const result = encodeFilePath("/") + expect(result).toBe("/") + }) + + test("should handle Linux path with all special chars", () => { + const path = "/path/to/file#with?special%chars&more.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("%23") // # + expect(result).toContain("%3F") // ? + expect(result).toContain("%25") // % + expect(result).toContain("%26") // & + }) + }) + + describe("macOS paths", () => { + test("should handle macOS absolute path", () => { + const macPath = "/Users/kelvin/Projects/opencode/README.md" + const result = encodeFilePath(macPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/Users/kelvin/Projects/opencode/README.md") + }) + + test("should handle macOS path with spaces", () => { + const macPath = "/Users/kelvin/My Documents/file.txt" + const result = encodeFilePath(macPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("My%20Documents") + }) + }) + + describe("Windows paths", () => { + test("should handle Windows absolute path with backslashes", () => { + const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + // Should create a valid, parseable URL + expect(() => new URL(fileUrl)).not.toThrow() + + const url = new URL(fileUrl) + expect(url.protocol).toBe("file:") + expect(url.pathname).toContain("README.bs.md") + expect(result).toBe("/D:/dev/projects/opencode/README.bs.md") + }) + + test("should handle mixed separator path (Windows + Unix)", () => { + // This is what happens in build-request-parts.ts when concatenating paths + const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md" + const result = encodeFilePath(mixedPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/D:/dev/projects/opencode/README.bs.md") + }) + + test("should handle Windows path with spaces", () => { + const windowsPath = "C:\\Program Files\\MyApp\\file with spaces.txt" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("Program%20Files") + expect(result).toContain("file%20with%20spaces.txt") + }) + + test("should handle Windows path with special chars in filename", () => { + const windowsPath = "D:\\projects\\file#name with ?marks.txt" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("file%23name%20with%20%3Fmarks.txt") + }) + + test("should handle Windows root directory", () => { + const windowsPath = "C:\\" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/C:/") + }) + + test("should handle Windows relative path with backslashes", () => { + const windowsPath = "src\\components\\App.tsx" + const result = encodeFilePath(windowsPath) + + // Relative paths shouldn't get the leading slash + expect(result).toBe("src/components/App.tsx") + }) + + test("should NOT create invalid URL like the bug report", () => { + // This is the exact scenario from bug report by @alexyaroshuk + const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + // The bug was creating: file://D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md + expect(result).not.toContain("%5C") // Should not have encoded backslashes + expect(result).not.toBe("D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md") + + // Should be valid + expect(() => new URL(fileUrl)).not.toThrow() + }) + + test("should handle lowercase drive letters", () => { + const windowsPath = "c:\\users\\test\\file.txt" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/c:/users/test/file.txt") + }) + }) + + describe("Cross-platform compatibility", () => { + test("should preserve Unix paths unchanged (except encoding)", () => { + const unixPath = "/usr/local/bin/app" + const result = encodeFilePath(unixPath) + expect(result).toBe("/usr/local/bin/app") + }) + + test("should normalize Windows paths for cross-platform use", () => { + const windowsPath = "C:\\Users\\test\\file.txt" + const result = encodeFilePath(windowsPath) + // Should convert to forward slashes and add leading / + expect(result).not.toContain("\\") + expect(result).toMatch(/^\/[A-Za-z]:\//) + }) + + test("should handle relative paths the same on all platforms", () => { + const unixRelative = "src/app.ts" + const windowsRelative = "src\\app.ts" + + const unixResult = encodeFilePath(unixRelative) + const windowsResult = encodeFilePath(windowsRelative) + + // Both should normalize to forward slashes + expect(unixResult).toBe("src/app.ts") + expect(windowsResult).toBe("src/app.ts") + }) + }) + + describe("Edge cases", () => { + test("should handle empty path", () => { + const result = encodeFilePath("") + expect(result).toBe("") + }) + + test("should handle path with multiple consecutive slashes", () => { + const result = encodeFilePath("//path//to///file.txt") + // Multiple slashes should be preserved (backend handles normalization) + expect(result).toBe("//path//to///file.txt") + }) + + test("should encode Unicode characters", () => { + const unicodePath = "/home/user/文档/README.md" + const result = encodeFilePath(unicodePath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + // Unicode should be encoded + expect(result).toContain("%E6%96%87%E6%A1%A3") + }) + + test("should handle already normalized Windows path", () => { + // Path that's already been normalized (has / before drive letter) + const alreadyNormalized = "/D:/path/file.txt" + const result = encodeFilePath(alreadyNormalized) + + // Should not add another leading slash + expect(result).toBe("/D:/path/file.txt") + expect(result).not.toContain("//D") + }) + + test("should handle just drive letter", () => { + const justDrive = "D:" + const result = encodeFilePath(justDrive) + const fileUrl = `file://${result}` + + expect(result).toBe("/D:") + expect(() => new URL(fileUrl)).not.toThrow() + }) + + test("should handle Windows path with trailing backslash", () => { + const trailingBackslash = "C:\\Users\\test\\" + const result = encodeFilePath(trailingBackslash) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/C:/Users/test/") + }) + + test("should handle very long paths", () => { + const longPath = "C:\\Users\\test\\" + "verylongdirectoryname\\".repeat(20) + "file.txt" + const result = encodeFilePath(longPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).not.toContain("\\") + }) + + test("should handle paths with dots", () => { + const pathWithDots = "C:\\Users\\..\\test\\.\\file.txt" + const result = encodeFilePath(pathWithDots) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + // Dots should be preserved (backend normalizes) + expect(result).toContain("..") + expect(result).toContain("/./") + }) + }) + + describe("Regression tests for PR #12424", () => { + test("should handle file with # in name", () => { + const path = "/path/to/file#name.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/path/to/file%23name.txt") + }) + + test("should handle file with ? in name", () => { + const path = "/path/to/file?name.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/path/to/file%3Fname.txt") + }) + + test("should handle file with % in name", () => { + const path = "/path/to/file%name.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/path/to/file%25name.txt") + }) + }) + + describe("Integration with file:// URL construction", () => { + test("should work with query parameters (Linux)", () => { + const path = "/home/user/file.txt" + const encoded = encodeFilePath(path) + const fileUrl = `file://${encoded}?start=10&end=20` + + const url = new URL(fileUrl) + expect(url.searchParams.get("start")).toBe("10") + expect(url.searchParams.get("end")).toBe("20") + expect(url.pathname).toBe("/home/user/file.txt") + }) + + test("should work with query parameters (Windows)", () => { + const path = "C:\\Users\\test\\file.txt" + const encoded = encodeFilePath(path) + const fileUrl = `file://${encoded}?start=10&end=20` + + const url = new URL(fileUrl) + expect(url.searchParams.get("start")).toBe("10") + expect(url.searchParams.get("end")).toBe("20") + }) + + test("should parse correctly in URL constructor (Linux)", () => { + const path = "/var/log/app.log" + const fileUrl = `file://${encodeFilePath(path)}` + const url = new URL(fileUrl) + + expect(url.protocol).toBe("file:") + expect(url.pathname).toBe("/var/log/app.log") + }) + + test("should parse correctly in URL constructor (Windows)", () => { + const path = "D:\\logs\\app.log" + const fileUrl = `file://${encodeFilePath(path)}` + const url = new URL(fileUrl) + + expect(url.protocol).toBe("file:") + expect(url.pathname).toContain("app.log") + }) + }) +}) diff --git a/Agent-backend/packages/app/src/context/file/path.ts b/Agent-backend/packages/app/src/context/file/path.ts new file mode 100644 index 000000000..53f072b6c --- /dev/null +++ b/Agent-backend/packages/app/src/context/file/path.ts @@ -0,0 +1,151 @@ +export function stripFileProtocol(input: string) { + if (!input.startsWith("file://")) return input + return input.slice("file://".length) +} + +export function stripQueryAndHash(input: string) { + const hashIndex = input.indexOf("#") + const queryIndex = input.indexOf("?") + + if (hashIndex !== -1 && queryIndex !== -1) { + return input.slice(0, Math.min(hashIndex, queryIndex)) + } + + if (hashIndex !== -1) return input.slice(0, hashIndex) + if (queryIndex !== -1) return input.slice(0, queryIndex) + return input +} + +export function unquoteGitPath(input: string) { + if (!input.startsWith('"')) return input + if (!input.endsWith('"')) return input + const body = input.slice(1, -1) + const bytes: number[] = [] + + for (let i = 0; i < body.length; i++) { + const char = body[i]! + if (char !== "\\") { + bytes.push(char.charCodeAt(0)) + continue + } + + const next = body[i + 1] + if (!next) { + bytes.push("\\".charCodeAt(0)) + continue + } + + if (next >= "0" && next <= "7") { + const chunk = body.slice(i + 1, i + 4) + const match = chunk.match(/^[0-7]{1,3}/) + if (!match) { + bytes.push(next.charCodeAt(0)) + i++ + continue + } + bytes.push(parseInt(match[0], 8)) + i += match[0].length + continue + } + + const escaped = + next === "n" + ? "\n" + : next === "r" + ? "\r" + : next === "t" + ? "\t" + : next === "b" + ? "\b" + : next === "f" + ? "\f" + : next === "v" + ? "\v" + : next === "\\" || next === '"' + ? next + : undefined + + bytes.push((escaped ?? next).charCodeAt(0)) + i++ + } + + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +export function decodeFilePath(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export function encodeFilePath(filepath: string): string { + // Normalize Windows paths: convert backslashes to forward slashes + let normalized = filepath.replace(/\\/g, "/") + + // Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs) + if (/^[A-Za-z]:/.test(normalized)) { + normalized = "/" + normalized + } + + // Encode each path segment (preserving forward slashes as path separators) + // Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers + // can reliably detect drives. + return normalized + .split("/") + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment + return encodeURIComponent(segment) + }) + .join("/") +} + +export function createPathHelpers(scope: () => string) { + const normalize = (input: string) => { + const root = scope() + + let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))) + + // Separator-agnostic prefix stripping for Cygwin/native Windows compatibility + // Only case-insensitive on Windows (drive letter or UNC paths) + const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\") + const canonRoot = windows ? root.replace(/\\/g, "/").toLowerCase() : root.replace(/\\/g, "/") + const canonPath = windows ? path.replace(/\\/g, "/").toLowerCase() : path.replace(/\\/g, "/") + if ( + canonPath.startsWith(canonRoot) && + (canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/") + ) { + // Slice from original path to preserve native separators + path = path.slice(root.length) + } + + if (path.startsWith("./") || path.startsWith(".\\")) { + path = path.slice(2) + } + + if (path.startsWith("/") || path.startsWith("\\")) { + path = path.slice(1) + } + return path + } + + const tab = (input: string) => { + const path = normalize(input) + return `file://${encodeFilePath(path)}` + } + + const pathFromTab = (tabValue: string) => { + if (!tabValue.startsWith("file://")) return + return normalize(tabValue) + } + + const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "") + + return { + normalize, + tab, + pathFromTab, + normalizeDir, + } +} diff --git a/Agent-backend/packages/app/src/context/file/tree-store.ts b/Agent-backend/packages/app/src/context/file/tree-store.ts new file mode 100644 index 000000000..a86051d28 --- /dev/null +++ b/Agent-backend/packages/app/src/context/file/tree-store.ts @@ -0,0 +1,170 @@ +import { createStore, produce, reconcile } from "solid-js/store" +import type { FileNode } from "@opencode-ai/sdk/v2" + +type DirectoryState = { + expanded: boolean + loaded?: boolean + loading?: boolean + error?: string + children?: string[] +} + +type TreeStoreOptions = { + scope: () => string + normalizeDir: (input: string) => string + list: (input: string) => Promise + onError: (message: string) => void +} + +export function createFileTreeStore(options: TreeStoreOptions) { + const [tree, setTree] = createStore<{ + node: Record + dir: Record + }>({ + node: {}, + dir: { "": { expanded: true } }, + }) + + const inflight = new Map>() + + const reset = () => { + inflight.clear() + setTree("node", reconcile({})) + setTree("dir", reconcile({})) + setTree("dir", "", { expanded: true }) + } + + const ensureDir = (path: string) => { + if (tree.dir[path]) return + setTree("dir", path, { expanded: false }) + } + + const listDir = (input: string, opts?: { force?: boolean }) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + + const current = tree.dir[dir] + if (!opts?.force && current?.loaded) return Promise.resolve() + + const pending = inflight.get(dir) + if (pending) return pending + + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + + const directory = options.scope() + + const promise = options + .list(dir) + .then((nodes) => { + if (options.scope() !== directory) return + const prevChildren = tree.dir[dir]?.children ?? [] + const nextChildren = nodes.map((node) => node.path) + const nextSet = new Set(nextChildren) + + setTree( + "node", + produce((draft) => { + const removedDirs: string[] = [] + + for (const child of prevChildren) { + if (nextSet.has(child)) continue + const existing = draft[child] + if (existing?.type === "directory") removedDirs.push(child) + delete draft[child] + } + + if (removedDirs.length > 0) { + const keys = Object.keys(draft) + for (const key of keys) { + for (const removed of removedDirs) { + if (!key.startsWith(removed + "/")) continue + delete draft[key] + break + } + } + } + + for (const node of nodes) { + draft[node.path] = node + } + }), + ) + + setTree( + "dir", + dir, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.children = nextChildren + }), + ) + }) + .catch((e) => { + if (options.scope() !== directory) return + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = false + draft.error = e.message + }), + ) + options.onError(e.message) + }) + .finally(() => { + inflight.delete(dir) + }) + + inflight.set(dir, promise) + return promise + } + + const expandDir = (input: string) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", true) + void listDir(dir) + } + + const collapseDir = (input: string) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", false) + } + + const dirState = (input: string) => { + const dir = options.normalizeDir(input) + return tree.dir[dir] + } + + const children = (input: string) => { + const dir = options.normalizeDir(input) + const ids = tree.dir[dir]?.children + if (!ids) return [] + const out: FileNode[] = [] + for (const id of ids) { + const node = tree.node[id] + if (node) out.push(node) + } + return out + } + + return { + listDir, + expandDir, + collapseDir, + dirState, + children, + node: (path: string) => tree.node[path], + isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded), + reset, + } +} diff --git a/Agent-backend/packages/app/src/context/file/types.ts b/Agent-backend/packages/app/src/context/file/types.ts new file mode 100644 index 000000000..7ce8a37c2 --- /dev/null +++ b/Agent-backend/packages/app/src/context/file/types.ts @@ -0,0 +1,41 @@ +import type { FileContent } from "@opencode-ai/sdk/v2" + +export type FileSelection = { + startLine: number + startChar: number + endLine: number + endChar: number +} + +export type SelectedLineRange = { + start: number + end: number + side?: "additions" | "deletions" + endSide?: "additions" | "deletions" +} + +export type FileViewState = { + scrollTop?: number + scrollLeft?: number + selectedLines?: SelectedLineRange | null +} + +export type FileState = { + path: string + name: string + loaded?: boolean + loading?: boolean + error?: string + content?: FileContent +} + +export function selectionFromLines(range: SelectedLineRange): FileSelection { + const startLine = Math.min(range.start, range.end) + const endLine = Math.max(range.start, range.end) + return { + startLine, + endLine, + startChar: 0, + endChar: 0, + } +} diff --git a/Agent-backend/packages/app/src/context/file/view-cache.ts b/Agent-backend/packages/app/src/context/file/view-cache.ts new file mode 100644 index 000000000..4c060174a --- /dev/null +++ b/Agent-backend/packages/app/src/context/file/view-cache.ts @@ -0,0 +1,146 @@ +import { createEffect, createRoot } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { Persist, persisted } from "@/utils/persist" +import { createScopedCache } from "@/utils/scoped-cache" +import type { FileViewState, SelectedLineRange } from "./types" + +const WORKSPACE_KEY = "__workspace__" +const MAX_FILE_VIEW_SESSIONS = 20 +const MAX_VIEW_FILES = 500 + +function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { + if (range.start <= range.end) return { ...range } + + const startSide = range.side + const endSide = range.endSide ?? startSide + + return { + ...range, + start: range.end, + end: range.start, + side: endSide, + endSide: startSide !== endSide ? startSide : undefined, + } +} + +function equalSelectedLines(a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) { + if (!a && !b) return true + if (!a || !b) return false + const left = normalizeSelectedLines(a) + const right = normalizeSelectedLines(b) + return ( + left.start === right.start && left.end === right.end && left.side === right.side && left.endSide === right.endSide + ) +} + +function createViewSession(dir: string, id: string | undefined) { + const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` + + const [view, setView, _, ready] = persisted( + Persist.scoped(dir, id, "file-view", [legacyViewKey]), + createStore<{ + file: Record + }>({ + file: {}, + }), + ) + + const meta = { pruned: false } + + const pruneView = (keep?: string) => { + const keys = Object.keys(view.file) + if (keys.length <= MAX_VIEW_FILES) return + + const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES) + if (drop.length === 0) return + + setView( + produce((draft) => { + for (const key of drop) { + delete draft.file[key] + } + }), + ) + } + + createEffect(() => { + if (!ready()) return + if (meta.pruned) return + meta.pruned = true + pruneView() + }) + + const scrollTop = (path: string) => view.file[path]?.scrollTop + const scrollLeft = (path: string) => view.file[path]?.scrollLeft + const selectedLines = (path: string) => view.file[path]?.selectedLines + + const setScrollTop = (path: string, top: number) => { + setView( + produce((draft) => { + const file = draft.file[path] ?? (draft.file[path] = {}) + if (file.scrollTop === top) return + file.scrollTop = top + }), + ) + pruneView(path) + } + + const setScrollLeft = (path: string, left: number) => { + setView( + produce((draft) => { + const file = draft.file[path] ?? (draft.file[path] = {}) + if (file.scrollLeft === left) return + file.scrollLeft = left + }), + ) + pruneView(path) + } + + const setSelectedLines = (path: string, range: SelectedLineRange | null) => { + const next = range ? normalizeSelectedLines(range) : null + setView( + produce((draft) => { + const file = draft.file[path] ?? (draft.file[path] = {}) + if (equalSelectedLines(file.selectedLines, next)) return + file.selectedLines = next + }), + ) + pruneView(path) + } + + return { + ready, + scrollTop, + scrollLeft, + selectedLines, + setScrollTop, + setScrollLeft, + setSelectedLines, + } +} + +export function createFileViewCache() { + const cache = createScopedCache( + (key) => { + const split = key.lastIndexOf("\n") + const dir = split >= 0 ? key.slice(0, split) : key + const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY + return createRoot((dispose) => ({ + value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id), + dispose, + })) + }, + { + maxEntries: MAX_FILE_VIEW_SESSIONS, + dispose: (entry) => entry.dispose(), + }, + ) + + return { + load: (dir: string, id: string | undefined) => { + const key = `${dir}\n${id ?? WORKSPACE_KEY}` + return cache.get(key).value + }, + clear: () => cache.clear(), + } +} diff --git a/Agent-backend/packages/app/src/context/file/watcher.test.ts b/Agent-backend/packages/app/src/context/file/watcher.test.ts new file mode 100644 index 000000000..9536b5253 --- /dev/null +++ b/Agent-backend/packages/app/src/context/file/watcher.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test" +import { invalidateFromWatcher } from "./watcher" + +describe("file watcher invalidation", () => { + test("reloads open files and refreshes loaded parent on add", () => { + const loads: string[] = [] + const refresh: string[] = [] + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/new.ts", + event: "add", + }, + }, + { + normalize: (input) => input, + hasFile: (path) => path === "src/new.ts", + loadFile: (path) => loads.push(path), + node: () => undefined, + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(loads).toEqual(["src/new.ts"]) + expect(refresh).toEqual(["src"]) + }) + + test("reloads files that are open in tabs", () => { + const loads: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/open.ts", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + isOpen: (path) => path === "src/open.ts", + loadFile: (path) => loads.push(path), + node: () => ({ + path: "src/open.ts", + type: "file", + name: "open.ts", + absolute: "/repo/src/open.ts", + ignored: false, + }), + isDirLoaded: () => false, + refreshDir: () => {}, + }, + ) + + expect(loads).toEqual(["src/open.ts"]) + }) + + test("refreshes only changed loaded directory nodes", () => { + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }), + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }, + ) + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/file.ts", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ + path: "src/file.ts", + type: "file", + name: "file.ts", + absolute: "/repo/src/file.ts", + ignored: false, + }), + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(refresh).toEqual(["src"]) + }) + + test("ignores invalid or git watcher updates", () => { + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: ".git/index.lock", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => true, + loadFile: () => { + throw new Error("should not load") + }, + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + invalidateFromWatcher( + { + type: "project.updated", + properties: {}, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(refresh).toEqual([]) + }) +}) diff --git a/Agent-backend/packages/app/src/context/file/watcher.ts b/Agent-backend/packages/app/src/context/file/watcher.ts new file mode 100644 index 000000000..fbf719927 --- /dev/null +++ b/Agent-backend/packages/app/src/context/file/watcher.ts @@ -0,0 +1,53 @@ +import type { FileNode } from "@opencode-ai/sdk/v2" + +type WatcherEvent = { + type: string + properties: unknown +} + +type WatcherOps = { + normalize: (input: string) => string + hasFile: (path: string) => boolean + isOpen?: (path: string) => boolean + loadFile: (path: string) => void + node: (path: string) => FileNode | undefined + isDirLoaded: (path: string) => boolean + refreshDir: (path: string) => void +} + +export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { + if (event.type !== "file.watcher.updated") return + const props = + typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined + const rawPath = typeof props?.file === "string" ? props.file : undefined + const kind = typeof props?.event === "string" ? props.event : undefined + if (!rawPath) return + if (!kind) return + + const path = ops.normalize(rawPath) + if (!path) return + if (path.startsWith(".git/")) return + + if (ops.hasFile(path) || ops.isOpen?.(path)) { + ops.loadFile(path) + } + + if (kind === "change") { + const dir = (() => { + if (path === "") return "" + const node = ops.node(path) + if (node?.type !== "directory") return + return path + })() + if (dir === undefined) return + if (!ops.isDirLoaded(dir)) return + ops.refreshDir(dir) + return + } + if (kind !== "add" && kind !== "unlink") return + + const parent = path.split("/").slice(0, -1).join("/") + if (!ops.isDirLoaded(parent)) return + + ops.refreshDir(parent) +} diff --git a/Agent-backend/packages/app/src/context/global-sdk.tsx b/Agent-backend/packages/app/src/context/global-sdk.tsx new file mode 100644 index 000000000..c1a87b95b --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sdk.tsx @@ -0,0 +1,230 @@ +import type { Event } from "@opencode-ai/sdk/v2/client" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { batch, onCleanup } from "solid-js" +import z from "zod" +import { createSdkForServer } from "@/utils/server" +import { usePlatform } from "./platform" +import { useServer } from "./server" + +const abortError = z.object({ + name: z.literal("AbortError"), +}) + +export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ + name: "GlobalSDK", + init: () => { + const server = useServer() + const platform = usePlatform() + const abort = new AbortController() + + const eventFetch = (() => { + if (!platform.fetch || !server.current) return + try { + const url = new URL(server.current.http.url) + const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" + if (url.protocol === "http:" && !loopback) return platform.fetch + } catch { + return + } + })() + + const currentServer = server.current + if (!currentServer) throw new Error("No server available") + + const eventSdk = createSdkForServer({ + signal: abort.signal, + fetch: eventFetch, + server: currentServer.http, + }) + const emitter = createGlobalEmitter<{ + [key: string]: Event + }>() + + type Queued = { directory: string; payload: Event } + const FLUSH_FRAME_MS = 16 + const STREAM_YIELD_MS = 8 + const RECONNECT_DELAY_MS = 250 + + let queue: Queued[] = [] + let buffer: Queued[] = [] + const coalesced = new Map() + const staleDeltas = new Set() + let timer: ReturnType | undefined + let last = 0 + + const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` + + const key = (directory: string, payload: Event) => { + if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}` + if (payload.type === "lsp.updated") return `lsp.updated:${directory}` + if (payload.type === "message.part.updated") { + const part = payload.properties.part + return `message.part.updated:${directory}:${part.messageID}:${part.id}` + } + } + + const flush = () => { + if (timer) clearTimeout(timer) + timer = undefined + + if (queue.length === 0) return + + const events = queue + const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined + queue = buffer + buffer = events + queue.length = 0 + coalesced.clear() + staleDeltas.clear() + + last = Date.now() + batch(() => { + for (const event of events) { + if (skip && event.payload.type === "message.part.delta") { + const props = event.payload.properties + if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue + } + emitter.emit(event.directory, event.payload) + } + }) + + buffer.length = 0 + } + + const schedule = () => { + if (timer) return + const elapsed = Date.now() - last + timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) + } + + let streamErrorLogged = false + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const aborted = (error: unknown) => abortError.safeParse(error).success + + let attempt: AbortController | undefined + const HEARTBEAT_TIMEOUT_MS = 15_000 + let lastEventAt = Date.now() + let heartbeat: ReturnType | undefined + const resetHeartbeat = () => { + lastEventAt = Date.now() + if (heartbeat) clearTimeout(heartbeat) + heartbeat = setTimeout(() => { + attempt?.abort() + }, HEARTBEAT_TIMEOUT_MS) + } + const clearHeartbeat = () => { + if (!heartbeat) return + clearTimeout(heartbeat) + heartbeat = undefined + } + + void (async () => { + while (!abort.signal.aborted) { + attempt = new AbortController() + lastEventAt = Date.now() + const onAbort = () => { + attempt?.abort() + } + abort.signal.addEventListener("abort", onAbort) + try { + const events = await eventSdk.global.event({ + signal: attempt.signal, + onSseError: (error) => { + if (aborted(error)) return + if (streamErrorLogged) return + streamErrorLogged = true + console.error("[global-sdk] event stream error", { + url: currentServer.http.url, + fetch: eventFetch ? "platform" : "webview", + error, + }) + }, + }) + let yielded = Date.now() + resetHeartbeat() + for await (const event of events.stream) { + resetHeartbeat() + streamErrorLogged = false + const directory = event.directory ?? "global" + const payload = event.payload + const k = key(directory, payload) + if (k) { + const i = coalesced.get(k) + if (i !== undefined) { + queue[i] = { directory, payload } + if (payload.type === "message.part.updated") { + const part = payload.properties.part + staleDeltas.add(deltaKey(directory, part.messageID, part.id)) + } + continue + } + coalesced.set(k, queue.length) + } + queue.push({ directory, payload }) + schedule() + + if (Date.now() - yielded < STREAM_YIELD_MS) continue + yielded = Date.now() + await wait(0) + } + } catch (error) { + if (!aborted(error) && !streamErrorLogged) { + streamErrorLogged = true + console.error("[global-sdk] event stream failed", { + url: currentServer.http.url, + fetch: eventFetch ? "platform" : "webview", + error, + }) + } + } finally { + abort.signal.removeEventListener("abort", onAbort) + attempt = undefined + clearHeartbeat() + } + + if (abort.signal.aborted) return + await wait(RECONNECT_DELAY_MS) + } + })().finally(flush) + + const onVisibility = () => { + if (typeof document === "undefined") return + if (document.visibilityState !== "visible") return + if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return + attempt?.abort() + } + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", onVisibility) + } + + onCleanup(() => { + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", onVisibility) + } + abort.abort() + flush() + }) + + const sdk = createSdkForServer({ + server: server.current.http, + fetch: platform.fetch, + throwOnError: true, + }) + + return { + url: currentServer.http.url, + client: sdk, + event: emitter, + createClient(opts: Omit[0], "server" | "fetch">) { + const s = server.current + if (!s) throw new Error("Server not available") + return createSdkForServer({ + server: s.http, + fetch: platform.fetch, + ...opts, + }) + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/global-sync.test.ts b/Agent-backend/packages/app/src/context/global-sync.test.ts new file mode 100644 index 000000000..7956057fd --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test" +import { + canDisposeDirectory, + estimateRootSessionTotal, + loadRootSessionsWithFallback, + pickDirectoriesToEvict, +} from "./global-sync" + +describe("pickDirectoriesToEvict", () => { + test("keeps pinned stores and evicts idle stores", () => { + const now = 5_000 + const picks = pickDirectoriesToEvict({ + stores: ["a", "b", "c", "d"], + state: new Map([ + ["a", { lastAccessAt: 1_000 }], + ["b", { lastAccessAt: 4_900 }], + ["c", { lastAccessAt: 4_800 }], + ["d", { lastAccessAt: 3_000 }], + ]), + pins: new Set(["a"]), + max: 2, + ttl: 1_500, + now, + }) + + expect(picks).toEqual(["d", "c"]) + }) +}) + +describe("loadRootSessionsWithFallback", () => { + test("uses limited roots query when supported", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + + const result = await loadRootSessionsWithFallback({ + directory: "dir", + limit: 10, + list: async (query) => { + calls.push(query) + return { data: [] } + }, + }) + + expect(result.data).toEqual([]) + expect(result.limited).toBe(true) + expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) + }) + + test("falls back to full roots query on limited-query failure", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + + const result = await loadRootSessionsWithFallback({ + directory: "dir", + limit: 25, + list: async (query) => { + calls.push(query) + if (query.limit) throw new Error("unsupported") + return { data: [] } + }, + }) + + expect(result.data).toEqual([]) + expect(result.limited).toBe(false) + expect(calls).toEqual([ + { directory: "dir", roots: true, limit: 25 }, + { directory: "dir", roots: true }, + ]) + }) +}) + +describe("estimateRootSessionTotal", () => { + test("keeps exact total for full fetches", () => { + expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) + }) + + test("marks has-more for full-limit limited fetches", () => { + expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11) + }) + + test("keeps exact total when limited fetch is under limit", () => { + expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9) + }) +}) + +describe("canDisposeDirectory", () => { + test("rejects pinned or inflight directories", () => { + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: true, + booting: false, + loadingSessions: false, + }), + ).toBe(false) + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: true, + loadingSessions: false, + }), + ).toBe(false) + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: false, + loadingSessions: true, + }), + ).toBe(false) + }) + + test("accepts idle unpinned directory store", () => { + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: false, + loadingSessions: false, + }), + ).toBe(true) + }) +}) diff --git a/Agent-backend/packages/app/src/context/global-sync.tsx b/Agent-backend/packages/app/src/context/global-sync.tsx new file mode 100644 index 000000000..4090699a8 --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync.tsx @@ -0,0 +1,407 @@ +import type { + Config, + OpencodeClient, + Path, + Project, + ProviderAuthResponse, + ProviderListResponse, + Todo, +} from "@opencode-ai/sdk/v2/client" +import { showToast } from "@opencode-ai/ui/toast" +import { getFilename } from "@opencode-ai/util/path" +import { + createContext, + getOwner, + Match, + onCleanup, + onMount, + type ParentProps, + Switch, + untrack, + useContext, +} from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { Persist, persisted } from "@/utils/persist" +import type { InitError } from "../pages/error" +import { useGlobalSDK } from "./global-sdk" +import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" +import { createChildStoreManager } from "./global-sync/child-store" +import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" +import { createRefreshQueue } from "./global-sync/queue" +import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { trimSessions } from "./global-sync/session-trim" +import type { ProjectMeta } from "./global-sync/types" +import { SESSION_RECENT_LIMIT } from "./global-sync/types" +import { sanitizeProject } from "./global-sync/utils" +import { formatServerError } from "@/utils/server-errors" + +type GlobalStore = { + ready: boolean + error?: InitError + path: Path + project: Project[] + session_todo: { + [sessionID: string]: Todo[] + } + provider: ProviderListResponse + provider_auth: ProviderAuthResponse + config: Config + reload: undefined | "pending" | "complete" +} + +function createGlobalSync() { + const globalSDK = useGlobalSDK() + const language = useLanguage() + const owner = getOwner() + if (!owner) throw new Error("GlobalSync must be created within owner") + + const sdkCache = new Map() + const booting = new Map>() + const sessionLoads = new Map>() + const sessionMeta = new Map() + + const [projectCache, setProjectCache, projectInit] = persisted( + Persist.global("globalSync.project", ["globalSync.project.v1"]), + createStore({ value: [] as Project[] }), + ) + + const [globalStore, setGlobalStore] = createStore({ + ready: false, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + project: projectCache.value, + session_todo: {}, + provider: { all: [], connected: [], default: {} }, + provider_auth: {}, + config: {}, + reload: undefined, + }) + + let active = true + let projectWritten = false + + onCleanup(() => { + active = false + }) + + const cacheProjects = () => { + setProjectCache( + "value", + untrack(() => globalStore.project.map(sanitizeProject)), + ) + } + + const setProjects = (next: Project[] | ((draft: Project[]) => void)) => { + projectWritten = true + if (typeof next === "function") { + setGlobalStore("project", produce(next)) + cacheProjects() + return + } + setGlobalStore("project", next) + cacheProjects() + } + + const setBootStore = ((...input: unknown[]) => { + if (input[0] === "project" && Array.isArray(input[1])) { + setProjects(input[1] as Project[]) + return input[1] + } + return (setGlobalStore as (...args: unknown[]) => unknown)(...input) + }) as typeof setGlobalStore + + const set = ((...input: unknown[]) => { + if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { + setProjects(input[1] as Project[] | ((draft: Project[]) => void)) + return input[1] + } + return (setGlobalStore as (...args: unknown[]) => unknown)(...input) + }) as typeof setGlobalStore + + if (projectInit instanceof Promise) { + void projectInit.then(() => { + if (!active) return + if (projectWritten) return + const cached = projectCache.value + if (cached.length === 0) return + setGlobalStore("project", cached) + }) + } + + const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { + if (!sessionID) return + if (!todos) { + setGlobalStore( + "session_todo", + produce((draft) => { + delete draft[sessionID] + }), + ) + return + } + setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" })) + } + + const paused = () => untrack(() => globalStore.reload) !== undefined + + const queue = createRefreshQueue({ + paused, + bootstrap, + bootstrapInstance, + }) + + const children = createChildStoreManager({ + owner, + isBooting: (directory) => booting.has(directory), + isLoadingSessions: (directory) => sessionLoads.has(directory), + onBootstrap: (directory) => { + void bootstrapInstance(directory) + }, + onDispose: (directory) => { + queue.clear(directory) + sessionMeta.delete(directory) + sdkCache.delete(directory) + }, + }) + + const sdkFor = (directory: string) => { + const cached = sdkCache.get(directory) + if (cached) return cached + const sdk = globalSDK.createClient({ + directory, + throwOnError: true, + }) + sdkCache.set(directory, sdk) + return sdk + } + + async function loadSessions(directory: string) { + const pending = sessionLoads.get(directory) + if (pending) return pending + + children.pin(directory) + const [store, setStore] = children.child(directory, { bootstrap: false }) + const meta = sessionMeta.get(directory) + if (meta && meta.limit >= store.limit) { + const next = trimSessions(store.session, { + limit: store.limit, + permission: store.permission, + }) + if (next.length !== store.session.length) { + setStore("session", reconcile(next, { key: "id" })) + cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo) + } + children.unpin(directory) + return + } + + const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) + const promise = loadRootSessionsWithFallback({ + directory, + limit, + list: (query) => globalSDK.client.session.list(query), + }) + .then((x) => { + const nonArchived = (x.data ?? []) + .filter((s) => !!s?.id) + .filter((s) => !s.time?.archived) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + const limit = store.limit + const childSessions = store.session.filter((s) => !!s.parentID) + const sessions = trimSessions([...nonArchived, ...childSessions], { + limit, + permission: store.permission, + }) + setStore( + "sessionTotal", + estimateRootSessionTotal({ + count: nonArchived.length, + limit: x.limit, + limited: x.limited, + }), + ) + setStore("session", reconcile(sessions, { key: "id" })) + cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) + sessionMeta.set(directory, { limit }) + }) + .catch((err) => { + console.error("Failed to load sessions", err) + const project = getFilename(directory) + showToast({ + variant: "error", + title: language.t("toast.session.listFailed.title", { project }), + description: formatServerError(err, language.t), + }) + }) + + sessionLoads.set(directory, promise) + promise.finally(() => { + sessionLoads.delete(directory) + children.unpin(directory) + }) + return promise + } + + async function bootstrapInstance(directory: string) { + if (!directory) return + const pending = booting.get(directory) + if (pending) return pending + + children.pin(directory) + const promise = (async () => { + const child = children.ensureChild(directory) + const cache = children.vcsCache.get(directory) + if (!cache) return + const sdk = sdkFor(directory) + await bootstrapDirectory({ + directory, + sdk, + store: child[0], + setStore: child[1], + vcsCache: cache, + loadSessions, + translate: language.t, + }) + })() + + booting.set(directory, promise) + promise.finally(() => { + booting.delete(directory) + children.unpin(directory) + }) + return promise + } + + const unsub = globalSDK.event.listen((e) => { + const directory = e.name + const event = e.details + + if (directory === "global") { + applyGlobalEvent({ + event, + project: globalStore.project, + refresh: queue.refresh, + setGlobalProject: setProjects, + }) + if (event.type === "server.connected" || event.type === "global.disposed") { + for (const directory of Object.keys(children.children)) { + queue.push(directory) + } + } + return + } + + const existing = children.children[directory] + if (!existing) return + children.mark(directory) + const [store, setStore] = existing + applyDirectoryEvent({ + event, + directory, + store, + setStore, + push: queue.push, + setSessionTodo, + vcsCache: children.vcsCache.get(directory), + loadLsp: () => { + sdkFor(directory) + .lsp.status() + .then((x) => setStore("lsp", x.data ?? [])) + }, + }) + }) + + onCleanup(unsub) + onCleanup(() => { + queue.dispose() + }) + onCleanup(() => { + for (const directory of Object.keys(children.children)) { + children.disposeDirectory(directory) + } + }) + + async function bootstrap() { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + connectErrorTitle: language.t("dialog.server.add.error"), + connectErrorDescription: language.t("error.globalSync.connectFailed", { + url: globalSDK.url, + }), + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + }) + } + + onMount(() => { + void bootstrap() + }) + + const projectApi = { + loadSessions, + meta(directory: string, patch: ProjectMeta) { + children.projectMeta(directory, patch) + }, + icon(directory: string, value: string | undefined) { + children.projectIcon(directory, value) + }, + } + + const updateConfig = async (config: Config) => { + setGlobalStore("reload", "pending") + return globalSDK.client.global.config + .update({ config }) + .then(bootstrap) + .then(() => { + queue.refresh() + setGlobalStore("reload", undefined) + queue.refresh() + }) + .catch((error) => { + setGlobalStore("reload", undefined) + throw error + }) + } + + return { + data: globalStore, + set, + get ready() { + return globalStore.ready + }, + get error() { + return globalStore.error + }, + child: children.child, + bootstrap, + updateConfig, + project: projectApi, + todo: { + set: setSessionTodo, + }, + } +} + +const GlobalSyncContext = createContext>() + +export function GlobalSyncProvider(props: ParentProps) { + const value = createGlobalSync() + return ( + + + {props.children} + + + ) +} + +export function useGlobalSync() { + const context = useContext(GlobalSyncContext) + if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider") + return context +} + +export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" +export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" diff --git a/Agent-backend/packages/app/src/context/global-sync/CLAUDE.md b/Agent-backend/packages/app/src/context/global-sync/CLAUDE.md new file mode 100644 index 000000000..15495fd9c --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/CLAUDE.md @@ -0,0 +1,16 @@ +# global-sync + +> **Path:** `Agent-backend/packages/app/src/context/global-sync/` + +See parent CLAUDE.md at `packages/app/CLAUDE.md` for detailed documentation of this directory. + +## Purpose + +Global SSE sync submodules for real-time state synchronization between the OpenCode server and the web UI. Handles bootstrap (initial state load), child store management, event reduction (processing SSE events into state updates), eviction of stale data, session caching/trimming, and an ordered event queue. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | The event reducer pattern (SSE events to state updates) and session cache/trim logic are directly relevant to Orbit's SSE-based sync architecture | +| Orbit CLI | `reference` | Same | diff --git a/Agent-backend/packages/app/src/context/global-sync/bootstrap.ts b/Agent-backend/packages/app/src/context/global-sync/bootstrap.ts new file mode 100644 index 000000000..8b1a3c48c --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/bootstrap.ts @@ -0,0 +1,206 @@ +import type { + Config, + OpencodeClient, + Path, + PermissionRequest, + Project, + ProviderAuthResponse, + ProviderListResponse, + QuestionRequest, + Todo, +} from "@opencode-ai/sdk/v2/client" +import { showToast } from "@opencode-ai/ui/toast" +import { getFilename } from "@opencode-ai/util/path" +import { retry } from "@opencode-ai/util/retry" +import { batch } from "solid-js" +import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import type { State, VcsCache } from "./types" +import { cmp, normalizeProviderList } from "./utils" +import { formatServerError } from "@/utils/server-errors" + +type GlobalStore = { + ready: boolean + path: Path + project: Project[] + session_todo: { + [sessionID: string]: Todo[] + } + provider: ProviderListResponse + provider_auth: ProviderAuthResponse + config: Config + reload: undefined | "pending" | "complete" +} + +export async function bootstrapGlobal(input: { + globalSDK: OpencodeClient + connectErrorTitle: string + connectErrorDescription: string + requestFailedTitle: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string + setGlobalStore: SetStoreFunction +}) { + const health = await input.globalSDK.global + .health() + .then((x) => x.data) + .catch(() => undefined) + if (!health?.healthy) { + showToast({ + variant: "error", + title: input.connectErrorTitle, + description: input.connectErrorDescription, + }) + input.setGlobalStore("ready", true) + return + } + + const tasks = [ + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), + retry(() => + input.globalSDK.provider.auth().then((x) => { + input.setGlobalStore("provider_auth", x.data ?? {}) + }), + ), + ] + + const results = await Promise.allSettled(tasks) + const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) + if (errors.length) { + const message = formatServerError(errors[0], input.translate) + const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" + showToast({ + variant: "error", + title: input.requestFailedTitle, + description: message + more, + }) + } + input.setGlobalStore("ready", true) +} + +function groupBySession(input: T[]) { + return input.reduce>((acc, item) => { + if (!item?.id || !item.sessionID) return acc + const list = acc[item.sessionID] + if (list) list.push(item) + if (!list) acc[item.sessionID] = [item] + return acc + }, {}) +} + +export async function bootstrapDirectory(input: { + directory: string + sdk: OpencodeClient + store: Store + setStore: SetStoreFunction + vcsCache: VcsCache + loadSessions: (directory: string) => Promise | void + translate: (key: string, vars?: Record) => string +}) { + if (input.store.status !== "complete") input.setStore("status", "loading") + + const blockingRequests = { + project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), + provider: () => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), + config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), + } + + try { + await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) + } catch (err) { + console.error("Failed to bootstrap instance", err) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: `Failed to reload ${project}`, + description: formatServerError(err, input.translate), + }) + input.setStore("status", "partial") + return + } + + if (input.store.status !== "complete") input.setStore("status", "partial") + + Promise.all([ + input.sdk.path.get().then((x) => input.setStore("path", x.data!)), + input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), + input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), + input.loadSessions(input.directory), + input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), + input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ]).then(() => { + input.setStore("status", "complete") + }) +} diff --git a/Agent-backend/packages/app/src/context/global-sync/child-store.test.ts b/Agent-backend/packages/app/src/context/global-sync/child-store.test.ts new file mode 100644 index 000000000..cec76ff87 --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/child-store.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { createRoot, getOwner } from "solid-js" +import { createStore } from "solid-js/store" +import type { State } from "./types" +import { createChildStoreManager } from "./child-store" + +const child = () => createStore({} as State) + +describe("createChildStoreManager", () => { + test("does not evict the active directory during mark", () => { + const owner = createRoot((dispose) => { + const current = getOwner() + dispose() + return current + }) + if (!owner) throw new Error("owner required") + + const manager = createChildStoreManager({ + owner, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onDispose() {}, + }) + + Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { + manager.children[directory] = child() + manager.pin(directory) + }) + + const directory = "/active" + manager.children[directory] = child() + manager.mark(directory) + + expect(manager.children[directory]).toBeDefined() + }) +}) diff --git a/Agent-backend/packages/app/src/context/global-sync/child-store.ts b/Agent-backend/packages/app/src/context/global-sync/child-store.ts new file mode 100644 index 000000000..e2ada244f --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/child-store.ts @@ -0,0 +1,270 @@ +import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import { Persist, persisted } from "@/utils/persist" +import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import { + DIR_IDLE_TTL_MS, + MAX_DIR_STORES, + type ChildOptions, + type DirState, + type IconCache, + type MetaCache, + type ProjectMeta, + type State, + type VcsCache, +} from "./types" +import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" + +export function createChildStoreManager(input: { + owner: Owner + isBooting: (directory: string) => boolean + isLoadingSessions: (directory: string) => boolean + onBootstrap: (directory: string) => void + onDispose: (directory: string) => void +}) { + const children: Record, SetStoreFunction]> = {} + const vcsCache = new Map() + const metaCache = new Map() + const iconCache = new Map() + const lifecycle = new Map() + const pins = new Map() + const ownerPins = new WeakMap>() + const disposers = new Map void>() + + const mark = (directory: string) => { + if (!directory) return + lifecycle.set(directory, { lastAccessAt: Date.now() }) + runEviction(directory) + } + + const pin = (directory: string) => { + if (!directory) return + pins.set(directory, (pins.get(directory) ?? 0) + 1) + mark(directory) + } + + const unpin = (directory: string) => { + if (!directory) return + const next = (pins.get(directory) ?? 0) - 1 + if (next > 0) { + pins.set(directory, next) + return + } + pins.delete(directory) + runEviction() + } + + const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0 + + const pinForOwner = (directory: string) => { + const current = getOwner() + if (!current) return + if (current === input.owner) return + const key = current as object + const set = ownerPins.get(key) + if (set?.has(directory)) return + if (set) set.add(directory) + if (!set) ownerPins.set(key, new Set([directory])) + pin(directory) + onCleanup(() => { + const set = ownerPins.get(key) + if (set) { + set.delete(directory) + if (set.size === 0) ownerPins.delete(key) + } + unpin(directory) + }) + } + + function disposeDirectory(directory: string) { + if ( + !canDisposeDirectory({ + directory, + hasStore: !!children[directory], + pinned: pinned(directory), + booting: input.isBooting(directory), + loadingSessions: input.isLoadingSessions(directory), + }) + ) { + return false + } + + vcsCache.delete(directory) + metaCache.delete(directory) + iconCache.delete(directory) + lifecycle.delete(directory) + const dispose = disposers.get(directory) + if (dispose) { + dispose() + disposers.delete(directory) + } + delete children[directory] + input.onDispose(directory) + return true + } + + function runEviction(skip?: string) { + const stores = Object.keys(children) + if (stores.length === 0) return + const list = pickDirectoriesToEvict({ + stores, + state: lifecycle, + pins: new Set(stores.filter(pinned)), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + now: Date.now(), + }).filter((directory) => directory !== skip) + if (list.length === 0) return + for (const directory of list) { + if (!disposeDirectory(directory)) continue + } + } + + function ensureChild(directory: string) { + if (!directory) console.error("No directory provided") + if (!children[directory]) { + const vcs = runWithOwner(input.owner, () => + persisted( + Persist.workspace(directory, "vcs", ["vcs.v1"]), + createStore({ value: undefined as VcsInfo | undefined }), + ), + ) + if (!vcs) throw new Error("Failed to create persisted cache") + const vcsStore = vcs[0] + vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) + + const meta = runWithOwner(input.owner, () => + persisted( + Persist.workspace(directory, "project", ["project.v1"]), + createStore({ value: undefined as ProjectMeta | undefined }), + ), + ) + if (!meta) throw new Error("Failed to create persisted project metadata") + metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) + + const icon = runWithOwner(input.owner, () => + persisted( + Persist.workspace(directory, "icon", ["icon.v1"]), + createStore({ value: undefined as string | undefined }), + ), + ) + if (!icon) throw new Error("Failed to create persisted project icon") + iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) + + const init = () => + createRoot((dispose) => { + const initialMeta = meta[0].value + const initialIcon = icon[0].value + const child = createStore({ + project: "", + projectMeta: initialMeta, + icon: initialIcon, + provider: { all: [], connected: [], default: {} }, + config: {}, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + status: "loading" as const, + agent: [], + command: [], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: vcsStore.value, + limit: 5, + message: {}, + part: {}, + }) + children[directory] = child + disposers.set(directory, dispose) + + const onPersistedInit = (init: Promise | string | null, run: () => void) => { + if (!(init instanceof Promise)) return + void init.then(() => { + if (children[directory] !== child) return + run() + }) + } + + onPersistedInit(vcs[2], () => { + const cached = vcsStore.value + if (!cached?.branch) return + child[1]("vcs", (value) => value ?? cached) + }) + + onPersistedInit(meta[2], () => { + if (child[0].projectMeta !== initialMeta) return + child[1]("projectMeta", meta[0].value) + }) + + onPersistedInit(icon[2], () => { + if (child[0].icon !== initialIcon) return + child[1]("icon", icon[0].value) + }) + }) + + runWithOwner(input.owner, init) + } + mark(directory) + const childStore = children[directory] + if (!childStore) throw new Error("Failed to create store") + return childStore + } + + function child(directory: string, options: ChildOptions = {}) { + const childStore = ensureChild(directory) + pinForOwner(directory) + const shouldBootstrap = options.bootstrap ?? true + if (shouldBootstrap && childStore[0].status === "loading") { + input.onBootstrap(directory) + } + return childStore + } + + function projectMeta(directory: string, patch: ProjectMeta) { + const [store, setStore] = ensureChild(directory) + const cached = metaCache.get(directory) + if (!cached) return + const previous = store.projectMeta ?? {} + const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon + const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands + const next = { + ...previous, + ...patch, + icon, + commands, + } + cached.setStore("value", next) + setStore("projectMeta", next) + } + + function projectIcon(directory: string, value: string | undefined) { + const [store, setStore] = ensureChild(directory) + const cached = iconCache.get(directory) + if (!cached) return + if (store.icon === value) return + cached.setStore("value", value) + setStore("icon", value) + } + + return { + children, + ensureChild, + child, + projectMeta, + projectIcon, + mark, + pin, + unpin, + pinned, + disposeDirectory, + runEviction, + vcsCache, + metaCache, + iconCache, + } +} diff --git a/Agent-backend/packages/app/src/context/global-sync/event-reducer.test.ts b/Agent-backend/packages/app/src/context/global-sync/event-reducer.test.ts new file mode 100644 index 000000000..cf2da135c --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/event-reducer.test.ts @@ -0,0 +1,552 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { createStore } from "solid-js/store" +import type { State } from "./types" +import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" + +const rootSession = (input: { id: string; parentID?: string; archived?: number }) => + ({ + id: input.id, + parentID: input.parentID, + time: { + created: 1, + updated: 1, + archived: input.archived, + }, + }) as Session + +const userMessage = (id: string, sessionID: string) => + ({ + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, + }) as Message + +const textPart = (id: string, sessionID: string, messageID: string) => + ({ + id, + sessionID, + messageID, + type: "text", + text: id, + }) as Part + +const permissionRequest = (id: string, sessionID: string, title = id) => + ({ + id, + sessionID, + permission: title, + patterns: ["*"], + metadata: {}, + always: [], + }) as PermissionRequest + +const questionRequest = (id: string, sessionID: string, title = id) => + ({ + id, + sessionID, + questions: [ + { + question: title, + header: title, + options: [{ label: title, description: title }], + }, + ], + }) as QuestionRequest + +const baseState = (input: Partial = {}) => + ({ + status: "complete", + agent: [], + command: [], + project: "", + projectMeta: undefined, + icon: undefined, + provider: {} as State["provider"], + config: {} as State["config"], + path: { directory: "/tmp" } as State["path"], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: undefined, + limit: 10, + message: {}, + part: {}, + ...input, + }) as State + +describe("applyGlobalEvent", () => { + test("upserts project.updated in sorted position", () => { + const project = [{ id: "a" }, { id: "c" }] as Project[] + let refreshCount = 0 + applyGlobalEvent({ + event: { type: "project.updated", properties: { id: "b" } }, + project, + refresh: () => { + refreshCount += 1 + }, + setGlobalProject(next) { + if (typeof next === "function") next(project) + }, + }) + + expect(project.map((x) => x.id)).toEqual(["a", "b", "c"]) + expect(refreshCount).toBe(0) + }) + + test("handles global.disposed by triggering refresh", () => { + let refreshCount = 0 + applyGlobalEvent({ + event: { type: "global.disposed" }, + project: [], + refresh: () => { + refreshCount += 1 + }, + setGlobalProject() {}, + }) + + expect(refreshCount).toBe(1) + }) + + test("handles server.connected by triggering refresh", () => { + let refreshCount = 0 + applyGlobalEvent({ + event: { type: "server.connected" }, + project: [], + refresh: () => { + refreshCount += 1 + }, + setGlobalProject() {}, + }) + + expect(refreshCount).toBe(1) + }) +}) + +describe("applyDirectoryEvent", () => { + test("inserts root sessions in sorted order and updates sessionTotal", () => { + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "b" })], + sessionTotal: 1, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.map((x) => x.id)).toEqual(["a", "b"]) + expect(store.sessionTotal).toBe(2) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.sessionTotal).toBe(2) + }) + + test("cleans session caches when archived", () => { + const message = userMessage("msg_1", "ses_1") + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })], + sessionTotal: 2, + message: { ses_1: [message] }, + part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] }, + session_diff: { ses_1: [] }, + todo: { ses_1: [] }, + permission: { ses_1: [] }, + question: { ses_1: [] }, + session_status: { ses_1: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.map((x) => x.id)).toEqual(["ses_2"]) + expect(store.sessionTotal).toBe(1) + expect(store.message.ses_1).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff.ses_1).toBeUndefined() + expect(store.todo.ses_1).toBeUndefined() + expect(store.permission.ses_1).toBeUndefined() + expect(store.question.ses_1).toBeUndefined() + expect(store.session_status.ses_1).toBeUndefined() + }) + + test("cleans session caches when deleted and decrements only root totals", () => { + const cases = [ + { info: rootSession({ id: "ses_1" }), expectedTotal: 1 }, + { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 }, + ] + + for (const item of cases) { + const message = userMessage("msg_1", item.info.id) + const [store, setStore] = createStore( + baseState({ + session: [ + rootSession({ id: "ses_1" }), + rootSession({ id: "ses_2", parentID: "ses_1" }), + rootSession({ id: "ses_3" }), + ], + sessionTotal: 2, + message: { [item.info.id]: [message] }, + part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] }, + session_diff: { [item.info.id]: [] }, + todo: { [item.info.id]: [] }, + permission: { [item.info.id]: [] }, + question: { [item.info.id]: [] }, + session_status: { [item.info.id]: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.deleted", properties: { info: item.info } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.find((x) => x.id === item.info.id)).toBeUndefined() + expect(store.sessionTotal).toBe(item.expectedTotal) + expect(store.message[item.info.id]).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff[item.info.id]).toBeUndefined() + expect(store.todo[item.info.id]).toBeUndefined() + expect(store.permission[item.info.id]).toBeUndefined() + expect(store.question[item.info.id]).toBeUndefined() + expect(store.session_status[item.info.id]).toBeUndefined() + } + }) + + test("cleans caches for trimmed sessions on session.created", () => { + const dropped = rootSession({ id: "ses_b" }) + const kept = rootSession({ id: "ses_a" }) + const message = userMessage("msg_1", dropped.id) + const todos: string[] = [] + const [store, setStore] = createStore( + baseState({ + limit: 1, + session: [dropped], + message: { [dropped.id]: [message] }, + part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] }, + session_diff: { [dropped.id]: [] }, + todo: { [dropped.id]: [] }, + permission: { [dropped.id]: [] }, + question: { [dropped.id]: [] }, + session_status: { [dropped.id]: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.created", properties: { info: kept } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + setSessionTodo(sessionID, value) { + if (value !== undefined) return + todos.push(sessionID) + }, + }) + + expect(store.session.map((x) => x.id)).toEqual([kept.id]) + expect(store.message[dropped.id]).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff[dropped.id]).toBeUndefined() + expect(store.todo[dropped.id]).toBeUndefined() + expect(store.permission[dropped.id]).toBeUndefined() + expect(store.question[dropped.id]).toBeUndefined() + expect(store.session_status[dropped.id]).toBeUndefined() + expect(todos).toEqual([dropped.id]) + }) + + test("cleanupDroppedSessionCaches clears part-only orphan state", () => { + const [store, setStore] = createStore( + baseState({ + session: [rootSession({ id: "ses_keep" })], + part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] }, + }), + ) + + cleanupDroppedSessionCaches(store, setStore, store.session) + + expect(store.part.msg_1).toBeUndefined() + }) + + test("upserts and removes messages while clearing orphaned parts", () => { + const sessionID = "ses_1" + const [store, setStore] = createStore( + baseState({ + message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] }, + part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"]) + + applyDirectoryEvent({ + event: { + type: "message.updated", + properties: { + info: { + ...userMessage("msg_2", sessionID), + role: "assistant", + } as Message, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant") + + applyDirectoryEvent({ + event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"]) + expect(store.part.msg_2).toBeUndefined() + }) + + test("upserts and prunes message parts", () => { + const sessionID = "ses_1" + const messageID = "msg_1" + const [store, setStore] = createStore( + baseState({ + part: { [messageID]: [textPart("prt_1", sessionID, messageID), textPart("prt_3", sessionID, messageID)] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "message.part.updated", properties: { part: textPart("prt_2", sessionID, messageID) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.part[messageID]?.map((x) => x.id)).toEqual(["prt_1", "prt_2", "prt_3"]) + + applyDirectoryEvent({ + event: { + type: "message.part.updated", + properties: { + part: { + ...textPart("prt_2", sessionID, messageID), + text: "changed", + } as Part, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + const updated = store.part[messageID]?.find((x) => x.id === "prt_2") + expect(updated?.type).toBe("text") + if (updated?.type === "text") expect(updated.text).toBe("changed") + + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_1" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_3" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.part[messageID]).toBeUndefined() + }) + + test("tracks permission and question request lifecycles", () => { + const sessionID = "ses_1" + const [store, setStore] = createStore( + baseState({ + permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] }, + question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID) }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_2", "perm_3"]) + + applyDirectoryEvent({ + event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID, "updated") }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated") + + applyDirectoryEvent({ + event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"]) + + applyDirectoryEvent({ + event: { type: "question.asked", properties: questionRequest("q_2", sessionID) }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"]) + + applyDirectoryEvent({ + event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated") + + applyDirectoryEvent({ + event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"]) + }) + + test("updates vcs branch in store and cache", () => { + const [store, setStore] = createStore(baseState()) + const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] }) + + applyDirectoryEvent({ + event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + vcsCache: { + store: cacheStore, + setStore: setCacheStore, + ready: () => true, + }, + }) + + expect(store.vcs).toEqual({ branch: "feature/test" }) + expect(cacheStore.value).toEqual({ branch: "feature/test" }) + }) + + test("routes disposal and lsp events to side-effect handlers", () => { + const [store, setStore] = createStore(baseState()) + const pushes: string[] = [] + let lspLoads = 0 + + applyDirectoryEvent({ + event: { type: "server.instance.disposed" }, + store, + setStore, + push(directory) { + pushes.push(directory) + }, + directory: "/tmp", + loadLsp() { + lspLoads += 1 + }, + }) + + applyDirectoryEvent({ + event: { type: "lsp.updated" }, + store, + setStore, + push(directory) { + pushes.push(directory) + }, + directory: "/tmp", + loadLsp() { + lspLoads += 1 + }, + }) + + expect(pushes).toEqual(["/tmp"]) + expect(lspLoads).toBe(1) + }) +}) diff --git a/Agent-backend/packages/app/src/context/global-sync/event-reducer.ts b/Agent-backend/packages/app/src/context/global-sync/event-reducer.ts new file mode 100644 index 000000000..b8eda0573 --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/event-reducer.ts @@ -0,0 +1,356 @@ +import { Binary } from "@opencode-ai/util/binary" +import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import type { + FileDiff, + Message, + Part, + PermissionRequest, + Project, + QuestionRequest, + Session, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" +import type { State, VcsCache } from "./types" +import { trimSessions } from "./session-trim" +import { dropSessionCaches } from "./session-cache" + +export function applyGlobalEvent(input: { + event: { type: string; properties?: unknown } + project: Project[] + setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void + refresh: () => void +}) { + if (input.event.type === "global.disposed" || input.event.type === "server.connected") { + input.refresh() + return + } + + if (input.event.type !== "project.updated") return + const properties = input.event.properties as Project + const result = Binary.search(input.project, properties.id, (s) => s.id) + if (result.found) { + input.setGlobalProject((draft) => { + draft[result.index] = { ...draft[result.index], ...properties } + }) + return + } + input.setGlobalProject((draft) => { + draft.splice(result.index, 0, properties) + }) +} + +function cleanupSessionCaches( + setStore: SetStoreFunction, + sessionID: string, + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, +) { + if (!sessionID) return + setSessionTodo?.(sessionID, undefined) + setStore( + produce((draft) => { + dropSessionCaches(draft, [sessionID]) + }), + ) +} + +export function cleanupDroppedSessionCaches( + store: Store, + setStore: SetStoreFunction, + next: Session[], + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, +) { + const keep = new Set(next.map((item) => item.id)) + const stale = [ + ...Object.keys(store.message), + ...Object.keys(store.session_diff), + ...Object.keys(store.todo), + ...Object.keys(store.permission), + ...Object.keys(store.question), + ...Object.keys(store.session_status), + ...Object.values(store.part) + .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID) + .filter((sessionID): sessionID is string => !!sessionID), + ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index) + if (stale.length === 0) return + for (const sessionID of stale) { + setSessionTodo?.(sessionID, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, stale) + }), + ) +} + +export function applyDirectoryEvent(input: { + event: { type: string; properties?: unknown } + store: Store + setStore: SetStoreFunction + push: (directory: string) => void + directory: string + loadLsp: () => void + vcsCache?: VcsCache + setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void +}) { + const event = input.event + switch (event.type) { + case "server.instance.disposed": { + input.push(input.directory) + return + } + case "session.created": { + const info = (event.properties as { info: Session }).info + const result = Binary.search(input.store.session, info.id, (s) => s.id) + if (result.found) { + input.setStore("session", result.index, reconcile(info)) + break + } + const next = input.store.session.slice() + next.splice(result.index, 0, info) + const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) + input.setStore("session", reconcile(trimmed, { key: "id" })) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) + if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) + break + } + case "session.updated": { + const info = (event.properties as { info: Session }).info + const result = Binary.search(input.store.session, info.id, (s) => s.id) + if (info.time.archived) { + if (result.found) { + input.setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) + if (info.parentID) break + input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } + if (result.found) { + input.setStore("session", result.index, reconcile(info)) + break + } + const next = input.store.session.slice() + next.splice(result.index, 0, info) + const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission }) + input.setStore("session", reconcile(trimmed, { key: "id" })) + cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) + break + } + case "session.deleted": { + const info = (event.properties as { info: Session }).info + const result = Binary.search(input.store.session, info.id, (s) => s.id) + if (result.found) { + input.setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) + if (info.parentID) break + input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } + case "session.diff": { + const props = event.properties as { sessionID: string; diff: FileDiff[] } + input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" })) + break + } + case "todo.updated": { + const props = event.properties as { sessionID: string; todos: Todo[] } + input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) + input.setSessionTodo?.(props.sessionID, props.todos) + break + } + case "session.status": { + const props = event.properties as { sessionID: string; status: SessionStatus } + input.setStore("session_status", props.sessionID, reconcile(props.status)) + break + } + case "message.updated": { + const info = (event.properties as { info: Message }).info + const messages = input.store.message[info.sessionID] + if (!messages) { + input.setStore("message", info.sessionID, [info]) + break + } + const result = Binary.search(messages, info.id, (m) => m.id) + if (result.found) { + input.setStore("message", info.sessionID, result.index, reconcile(info)) + break + } + input.setStore( + "message", + info.sessionID, + produce((draft) => { + draft.splice(result.index, 0, info) + }), + ) + break + } + case "message.removed": { + const props = event.properties as { sessionID: string; messageID: string } + input.setStore( + produce((draft) => { + const messages = draft.message[props.sessionID] + if (messages) { + const result = Binary.search(messages, props.messageID, (m) => m.id) + if (result.found) messages.splice(result.index, 1) + } + delete draft.part[props.messageID] + }), + ) + break + } + case "message.part.updated": { + const part = (event.properties as { part: Part }).part + const parts = input.store.part[part.messageID] + if (!parts) { + input.setStore("part", part.messageID, [part]) + break + } + const result = Binary.search(parts, part.id, (p) => p.id) + if (result.found) { + input.setStore("part", part.messageID, result.index, reconcile(part)) + break + } + input.setStore( + "part", + part.messageID, + produce((draft) => { + draft.splice(result.index, 0, part) + }), + ) + break + } + case "message.part.removed": { + const props = event.properties as { messageID: string; partID: string } + const parts = input.store.part[props.messageID] + if (!parts) break + const result = Binary.search(parts, props.partID, (p) => p.id) + if (result.found) { + input.setStore( + produce((draft) => { + const list = draft.part[props.messageID] + if (!list) return + const next = Binary.search(list, props.partID, (p) => p.id) + if (!next.found) return + list.splice(next.index, 1) + if (list.length === 0) delete draft.part[props.messageID] + }), + ) + } + break + } + case "message.part.delta": { + const props = event.properties as { messageID: string; partID: string; field: string; delta: string } + const parts = input.store.part[props.messageID] + if (!parts) break + const result = Binary.search(parts, props.partID, (p) => p.id) + if (!result.found) break + input.setStore( + "part", + props.messageID, + produce((draft) => { + const part = draft[result.index] + const field = props.field as keyof typeof part + const existing = part[field] as string | undefined + ;(part[field] as string) = (existing ?? "") + props.delta + }), + ) + break + } + case "vcs.branch.updated": { + const props = event.properties as { branch: string } + if (input.store.vcs?.branch === props.branch) break + const next = { branch: props.branch } + input.setStore("vcs", next) + if (input.vcsCache) input.vcsCache.setStore("value", next) + break + } + case "permission.asked": { + const permission = event.properties as PermissionRequest + const permissions = input.store.permission[permission.sessionID] + if (!permissions) { + input.setStore("permission", permission.sessionID, [permission]) + break + } + const result = Binary.search(permissions, permission.id, (p) => p.id) + if (result.found) { + input.setStore("permission", permission.sessionID, result.index, reconcile(permission)) + break + } + input.setStore( + "permission", + permission.sessionID, + produce((draft) => { + draft.splice(result.index, 0, permission) + }), + ) + break + } + case "permission.replied": { + const props = event.properties as { sessionID: string; requestID: string } + const permissions = input.store.permission[props.sessionID] + if (!permissions) break + const result = Binary.search(permissions, props.requestID, (p) => p.id) + if (!result.found) break + input.setStore( + "permission", + props.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } + case "question.asked": { + const question = event.properties as QuestionRequest + const questions = input.store.question[question.sessionID] + if (!questions) { + input.setStore("question", question.sessionID, [question]) + break + } + const result = Binary.search(questions, question.id, (q) => q.id) + if (result.found) { + input.setStore("question", question.sessionID, result.index, reconcile(question)) + break + } + input.setStore( + "question", + question.sessionID, + produce((draft) => { + draft.splice(result.index, 0, question) + }), + ) + break + } + case "question.replied": + case "question.rejected": { + const props = event.properties as { sessionID: string; requestID: string } + const questions = input.store.question[props.sessionID] + if (!questions) break + const result = Binary.search(questions, props.requestID, (q) => q.id) + if (!result.found) break + input.setStore( + "question", + props.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } + case "lsp.updated": { + input.loadLsp() + break + } + } +} diff --git a/Agent-backend/packages/app/src/context/global-sync/eviction.ts b/Agent-backend/packages/app/src/context/global-sync/eviction.ts new file mode 100644 index 000000000..676a6ee17 --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/eviction.ts @@ -0,0 +1,28 @@ +import type { DisposeCheck, EvictPlan } from "./types" + +export function pickDirectoriesToEvict(input: EvictPlan) { + const overflow = Math.max(0, input.stores.length - input.max) + let pendingOverflow = overflow + const sorted = input.stores + .filter((dir) => !input.pins.has(dir)) + .slice() + .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0)) + const output: string[] = [] + for (const dir of sorted) { + const last = input.state.get(dir)?.lastAccessAt ?? 0 + const idle = input.now - last >= input.ttl + if (!idle && pendingOverflow <= 0) continue + output.push(dir) + if (pendingOverflow > 0) pendingOverflow -= 1 + } + return output +} + +export function canDisposeDirectory(input: DisposeCheck) { + if (!input.directory) return false + if (!input.hasStore) return false + if (input.pinned) return false + if (input.booting) return false + if (input.loadingSessions) return false + return true +} diff --git a/Agent-backend/packages/app/src/context/global-sync/queue.ts b/Agent-backend/packages/app/src/context/global-sync/queue.ts new file mode 100644 index 000000000..c3468583b --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/queue.ts @@ -0,0 +1,83 @@ +type QueueInput = { + paused: () => boolean + bootstrap: () => Promise + bootstrapInstance: (directory: string) => Promise | void +} + +export function createRefreshQueue(input: QueueInput) { + const queued = new Set() + let root = false + let running = false + let timer: ReturnType | undefined + + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + + const take = (count: number) => { + if (queued.size === 0) return [] as string[] + const items: string[] = [] + for (const item of queued) { + queued.delete(item) + items.push(item) + if (items.length >= count) break + } + return items + } + + const schedule = () => { + if (timer) return + timer = setTimeout(() => { + timer = undefined + void drain() + }, 0) + } + + const push = (directory: string) => { + if (!directory) return + queued.add(directory) + if (input.paused()) return + schedule() + } + + const refresh = () => { + root = true + if (input.paused()) return + schedule() + } + + async function drain() { + if (running) return + running = true + try { + while (true) { + if (input.paused()) return + if (root) { + root = false + await input.bootstrap() + await tick() + continue + } + const dirs = take(2) + if (dirs.length === 0) return + await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir))) + await tick() + } + } finally { + running = false + if (input.paused()) return + if (root || queued.size) schedule() + } + } + + return { + push, + refresh, + clear(directory: string) { + queued.delete(directory) + }, + dispose() { + if (!timer) return + clearTimeout(timer) + timer = undefined + }, + } +} diff --git a/Agent-backend/packages/app/src/context/global-sync/session-cache.test.ts b/Agent-backend/packages/app/src/context/global-sync/session-cache.test.ts new file mode 100644 index 000000000..8e11110e3 --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/session-cache.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" +import type { + FileDiff, + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" +import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" + +const msg = (id: string, sessionID: string) => + ({ + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, + }) as Message + +const part = (id: string, sessionID: string, messageID: string) => + ({ + id, + sessionID, + messageID, + type: "text", + text: id, + }) as Part + +describe("app session cache", () => { + test("dropSessionCaches clears orphaned parts without message rows", () => { + const store: { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record + } = { + session_status: { ses_1: { type: "busy" } as SessionStatus }, + session_diff: { ses_1: [] }, + todo: { ses_1: [] as Todo[] }, + message: {}, + part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, + permission: { ses_1: [] as PermissionRequest[] }, + question: { ses_1: [] as QuestionRequest[] }, + } + + dropSessionCaches(store, ["ses_1"]) + + expect(store.message.ses_1).toBeUndefined() + expect(store.part.msg_1).toBeUndefined() + expect(store.todo.ses_1).toBeUndefined() + expect(store.session_diff.ses_1).toBeUndefined() + expect(store.session_status.ses_1).toBeUndefined() + expect(store.permission.ses_1).toBeUndefined() + expect(store.question.ses_1).toBeUndefined() + }) + + test("dropSessionCaches clears message-backed parts", () => { + const m = msg("msg_1", "ses_1") + const store: { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record + } = { + session_status: {}, + session_diff: {}, + todo: {}, + message: { ses_1: [m] }, + part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, + permission: {}, + question: {}, + } + + dropSessionCaches(store, ["ses_1"]) + + expect(store.message.ses_1).toBeUndefined() + expect(store.part[m.id]).toBeUndefined() + }) + + test("pickSessionCacheEvictions preserves requested sessions", () => { + const seen = new Set(["ses_1", "ses_2", "ses_3"]) + + const stale = pickSessionCacheEvictions({ + seen, + keep: "ses_4", + limit: 2, + preserve: ["ses_1"], + }) + + expect(stale).toEqual(["ses_2", "ses_3"]) + expect([...seen]).toEqual(["ses_1", "ses_4"]) + }) +}) diff --git a/Agent-backend/packages/app/src/context/global-sync/session-cache.ts b/Agent-backend/packages/app/src/context/global-sync/session-cache.ts new file mode 100644 index 000000000..0177ebbe1 --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/session-cache.ts @@ -0,0 +1,62 @@ +import type { + FileDiff, + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + Todo, +} from "@opencode-ai/sdk/v2/client" + +export const SESSION_CACHE_LIMIT = 40 + +type SessionCache = { + session_status: Record + session_diff: Record + todo: Record + message: Record + part: Record + permission: Record + question: Record +} + +export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { + const stale = new Set(Array.from(sessionIDs).filter(Boolean)) + if (stale.size === 0) return + + for (const key of Object.keys(store.part)) { + const parts = store.part[key] + if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue + delete store.part[key] + } + + for (const sessionID of stale) { + delete store.message[sessionID] + delete store.todo[sessionID] + delete store.session_diff[sessionID] + delete store.session_status[sessionID] + delete store.permission[sessionID] + delete store.question[sessionID] + } +} + +export function pickSessionCacheEvictions(input: { + seen: Set + keep: string + limit: number + preserve?: Iterable +}) { + const stale: string[] = [] + const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])]) + if (input.seen.has(input.keep)) input.seen.delete(input.keep) + input.seen.add(input.keep) + for (const id of input.seen) { + if (input.seen.size - stale.length <= input.limit) break + if (keep.has(id)) continue + stale.push(id) + } + for (const id of stale) { + input.seen.delete(id) + } + return stale +} diff --git a/Agent-backend/packages/app/src/context/global-sync/session-load.ts b/Agent-backend/packages/app/src/context/global-sync/session-load.ts new file mode 100644 index 000000000..3693dcb46 --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/session-load.ts @@ -0,0 +1,25 @@ +import type { RootLoadArgs } from "./types" + +export async function loadRootSessionsWithFallback(input: RootLoadArgs) { + try { + const result = await input.list({ directory: input.directory, roots: true, limit: input.limit }) + return { + data: result.data, + limit: input.limit, + limited: true, + } as const + } catch { + const result = await input.list({ directory: input.directory, roots: true }) + return { + data: result.data, + limit: input.limit, + limited: false, + } as const + } +} + +export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) { + if (!input.limited) return input.count + if (input.count < input.limit) return input.count + return input.count + 1 +} diff --git a/Agent-backend/packages/app/src/context/global-sync/session-trim.test.ts b/Agent-backend/packages/app/src/context/global-sync/session-trim.test.ts new file mode 100644 index 000000000..be12c074b --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/session-trim.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { trimSessions } from "./session-trim" + +const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => + ({ + id: input.id, + parentID: input.parentID, + time: { + created: input.created, + updated: input.updated, + archived: input.archived, + }, + }) as Session + +describe("trimSessions", () => { + test("keeps base roots and recent roots beyond the limit", () => { + const now = 1_000_000 + const list = [ + session({ id: "a", created: now - 100_000 }), + session({ id: "b", created: now - 90_000 }), + session({ id: "c", created: now - 80_000 }), + session({ id: "d", created: now - 70_000, updated: now - 1_000 }), + session({ id: "e", created: now - 60_000, archived: now - 10 }), + ] + + const result = trimSessions(list, { limit: 2, permission: {}, now }) + expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"]) + }) + + test("keeps children when root is kept, permission exists, or child is recent", () => { + const now = 1_000_000 + const list = [ + session({ id: "root-1", created: now - 1000 }), + session({ id: "root-2", created: now - 2000 }), + session({ id: "z-root", created: now - 30_000_000 }), + session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }), + session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }), + session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }), + session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }), + ] + + const result = trimSessions(list, { + limit: 2, + permission: { + "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest], + }, + now, + }) + + expect(result.map((x) => x.id)).toEqual([ + "child-kept-by-permission", + "child-kept-by-recency", + "child-kept-by-root", + "root-1", + "root-2", + ]) + }) +}) diff --git a/Agent-backend/packages/app/src/context/global-sync/session-trim.ts b/Agent-backend/packages/app/src/context/global-sync/session-trim.ts new file mode 100644 index 000000000..800ba74a6 --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/session-trim.ts @@ -0,0 +1,56 @@ +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { cmp } from "./utils" +import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" + +export function sessionUpdatedAt(session: Session) { + return session.time.updated ?? session.time.created +} + +export function compareSessionRecent(a: Session, b: Session) { + const aUpdated = sessionUpdatedAt(a) + const bUpdated = sessionUpdatedAt(b) + if (aUpdated !== bUpdated) return bUpdated - aUpdated + return cmp(a.id, b.id) +} + +export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { + if (limit <= 0) return [] as Session[] + const selected: Session[] = [] + const seen = new Set() + for (const session of sessions) { + if (!session?.id) continue + if (seen.has(session.id)) continue + seen.add(session.id) + if (sessionUpdatedAt(session) <= cutoff) continue + const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0) + if (index === -1) selected.push(session) + if (index !== -1) selected.splice(index, 0, session) + if (selected.length > limit) selected.pop() + } + return selected +} + +export function trimSessions( + input: Session[], + options: { limit: number; permission: Record; now?: number }, +) { + const limit = Math.max(0, options.limit) + const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW + const all = input + .filter((s) => !!s?.id) + .filter((s) => !s.time?.archived) + .sort((a, b) => cmp(a.id, b.id)) + const roots = all.filter((s) => !s.parentID) + const children = all.filter((s) => !!s.parentID) + const base = roots.slice(0, limit) + const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff) + const keepRoots = [...base, ...recent] + const keepRootIds = new Set(keepRoots.map((s) => s.id)) + const keepChildren = children.filter((s) => { + if (s.parentID && keepRootIds.has(s.parentID)) return true + const perms = options.permission[s.id] ?? [] + if (perms.length > 0) return true + return sessionUpdatedAt(s) > cutoff + }) + return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) +} diff --git a/Agent-backend/packages/app/src/context/global-sync/types.ts b/Agent-backend/packages/app/src/context/global-sync/types.ts new file mode 100644 index 000000000..c61dc337d --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/types.ts @@ -0,0 +1,133 @@ +import type { + Agent, + Command, + Config, + FileDiff, + LspStatus, + McpStatus, + Message, + Part, + Path, + PermissionRequest, + Project, + ProviderListResponse, + QuestionRequest, + Session, + SessionStatus, + Todo, + VcsInfo, +} from "@opencode-ai/sdk/v2/client" +import type { Accessor } from "solid-js" +import type { SetStoreFunction, Store } from "solid-js/store" + +export type ProjectMeta = { + name?: string + icon?: { + override?: string + color?: string + } + commands?: { + start?: string + } +} + +export type State = { + status: "loading" | "partial" | "complete" + agent: Agent[] + command: Command[] + project: string + projectMeta: ProjectMeta | undefined + icon: string | undefined + provider: ProviderListResponse + config: Config + path: Path + session: Session[] + sessionTotal: number + session_status: { + [sessionID: string]: SessionStatus + } + session_diff: { + [sessionID: string]: FileDiff[] + } + todo: { + [sessionID: string]: Todo[] + } + permission: { + [sessionID: string]: PermissionRequest[] + } + question: { + [sessionID: string]: QuestionRequest[] + } + mcp: { + [name: string]: McpStatus + } + lsp: LspStatus[] + vcs: VcsInfo | undefined + limit: number + message: { + [sessionID: string]: Message[] + } + part: { + [messageID: string]: Part[] + } +} + +export type VcsCache = { + store: Store<{ value: VcsInfo | undefined }> + setStore: SetStoreFunction<{ value: VcsInfo | undefined }> + ready: Accessor +} + +export type MetaCache = { + store: Store<{ value: ProjectMeta | undefined }> + setStore: SetStoreFunction<{ value: ProjectMeta | undefined }> + ready: Accessor +} + +export type IconCache = { + store: Store<{ value: string | undefined }> + setStore: SetStoreFunction<{ value: string | undefined }> + ready: Accessor +} + +export type ChildOptions = { + bootstrap?: boolean +} + +export type DirState = { + lastAccessAt: number +} + +export type EvictPlan = { + stores: string[] + state: Map + pins: Set + max: number + ttl: number + now: number +} + +export type DisposeCheck = { + directory: string + hasStore: boolean + pinned: boolean + booting: boolean + loadingSessions: boolean +} + +export type RootLoadArgs = { + directory: string + limit: number + list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> +} + +export type RootLoadResult = { + data?: Session[] + limit: number + limited: boolean +} + +export const MAX_DIR_STORES = 30 +export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 +export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 +export const SESSION_RECENT_LIMIT = 50 diff --git a/Agent-backend/packages/app/src/context/global-sync/utils.ts b/Agent-backend/packages/app/src/context/global-sync/utils.ts new file mode 100644 index 000000000..6b78134a6 --- /dev/null +++ b/Agent-backend/packages/app/src/context/global-sync/utils.ts @@ -0,0 +1,25 @@ +import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" + +export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { + return { + ...input, + all: input.all.map((provider) => ({ + ...provider, + models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")), + })), + } +} + +export function sanitizeProject(project: Project) { + if (!project.icon?.url && !project.icon?.override) return project + return { + ...project, + icon: { + ...project.icon, + url: undefined, + override: undefined, + }, + } +} diff --git a/Agent-backend/packages/app/src/context/highlights.tsx b/Agent-backend/packages/app/src/context/highlights.tsx new file mode 100644 index 000000000..476209e41 --- /dev/null +++ b/Agent-backend/packages/app/src/context/highlights.tsx @@ -0,0 +1,232 @@ +import { createEffect, createSignal, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { persisted } from "@/utils/persist" +import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes" + +const CHANGELOG_URL = "https://opencode.ai/changelog.json" + +type Store = { + version?: string +} + +type ParsedRelease = { + tag?: string + highlights: Highlight[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function getText(value: unknown): string | undefined { + if (typeof value === "string") { + const text = value.trim() + return text.length > 0 ? text : undefined + } + + if (typeof value === "number") return String(value) + return +} + +function normalizeVersion(value: string | undefined) { + const text = value?.trim() + if (!text) return + return text.startsWith("v") || text.startsWith("V") ? text.slice(1) : text +} + +function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined { + if (!isRecord(value)) return + const type = getText(value.type)?.toLowerCase() + const src = getText(value.src) ?? getText(value.url) + if (!src) return + if (type !== "image" && type !== "video") return + + return { type, src, alt } +} + +function parseHighlight(value: unknown): Highlight | undefined { + if (!isRecord(value)) return + + const title = getText(value.title) + if (!title) return + + const description = getText(value.description) ?? getText(value.shortDescription) + if (!description) return + + const media = parseMedia(value.media, title) + return { title, description, media } +} + +function parseRelease(value: unknown): ParsedRelease | undefined { + if (!isRecord(value)) return + const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name) + + if (!Array.isArray(value.highlights)) { + return { tag, highlights: [] } + } + + const highlights = value.highlights.flatMap((group) => { + if (!isRecord(group)) return [] + + const source = getText(group.source) + if (!source) return [] + if (!source.toLowerCase().includes("desktop")) return [] + + if (Array.isArray(group.items)) { + return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined) + } + + const item = parseHighlight(group) + if (!item) return [] + return [item] + }) + + return { tag, highlights } +} + +function parseChangelog(value: unknown): ParsedRelease[] | undefined { + if (Array.isArray(value)) { + return value.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined) + } + + if (!isRecord(value)) return + if (!Array.isArray(value.releases)) return + + return value.releases.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined) +} + +function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) { + const current = normalizeVersion(input.current) + const previous = normalizeVersion(input.previous) + const releases = input.releases + + const start = (() => { + if (!current) return 0 + const index = releases.findIndex((release) => normalizeVersion(release.tag) === current) + return index === -1 ? 0 : index + })() + + const end = (() => { + if (!previous) return releases.length + const index = releases.findIndex((release, i) => i >= start && normalizeVersion(release.tag) === previous) + return index === -1 ? releases.length : index + })() + + const highlights = releases.slice(start, end).flatMap((release) => release.highlights) + const seen = new Set() + const unique = highlights.filter((highlight) => { + const key = dedupeKey(highlight) + if (seen.has(key)) return false + seen.add(key) + return true + }) + return unique.slice(0, 5) +} + +function dedupeKey(highlight: Highlight) { + return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n") +} + +function loadReleaseHighlights(value: unknown, current?: string, previous?: string) { + const releases = parseChangelog(value) + if (!releases?.length) return [] + return sliceHighlights({ releases, current, previous }) +} + +export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ + name: "Highlights", + gate: false, + init: () => { + const platform = usePlatform() + const dialog = useDialog() + const settings = useSettings() + const [store, setStore, _, ready] = persisted("highlights.v1", createStore({ version: undefined })) + + const [from, setFrom] = createSignal(undefined) + const [to, setTo] = createSignal(undefined) + const state = { started: false } + let timer: ReturnType | undefined + + const clearTimer = () => { + if (timer === undefined) return + clearTimeout(timer) + timer = undefined + } + + const markSeen = () => { + if (!platform.version) return + setStore("version", platform.version) + } + + const start = (previous: string) => { + if (!settings.general.releaseNotes()) { + markSeen() + return + } + + const fetcher = platform.fetch ?? fetch + const controller = new AbortController() + onCleanup(() => { + controller.abort() + clearTimer() + }) + + fetcher(CHANGELOG_URL, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + .then((response) => (response.ok ? (response.json() as Promise) : undefined)) + .then((json) => { + if (!json) return + const highlights = loadReleaseHighlights(json, platform.version, previous) + if (controller.signal.aborted) return + + if (highlights.length === 0) { + markSeen() + return + } + + timer = setTimeout(() => { + timer = undefined + markSeen() + dialog.show(() => ) + }, 500) + }) + .catch(() => undefined) + } + + createEffect(() => { + if (state.started) return + if (!ready()) return + if (!settings.ready()) return + if (!platform.version) return + state.started = true + + const previous = store.version + if (!previous) { + setStore("version", platform.version) + return + } + + if (previous === platform.version) return + + setFrom(previous) + setTo(platform.version) + start(previous) + }) + + return { + ready, + from, + to, + get last() { + return store.version + }, + markSeen, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/language.tsx b/Agent-backend/packages/app/src/context/language.tsx new file mode 100644 index 000000000..b1edd541c --- /dev/null +++ b/Agent-backend/packages/app/src/context/language.tsx @@ -0,0 +1,248 @@ +import * as i18n from "@solid-primitives/i18n" +import { createEffect, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { Persist, persisted } from "@/utils/persist" +import { dict as en } from "@/i18n/en" +import { dict as zh } from "@/i18n/zh" +import { dict as zht } from "@/i18n/zht" +import { dict as ko } from "@/i18n/ko" +import { dict as de } from "@/i18n/de" +import { dict as es } from "@/i18n/es" +import { dict as fr } from "@/i18n/fr" +import { dict as da } from "@/i18n/da" +import { dict as ja } from "@/i18n/ja" +import { dict as pl } from "@/i18n/pl" +import { dict as ru } from "@/i18n/ru" +import { dict as ar } from "@/i18n/ar" +import { dict as no } from "@/i18n/no" +import { dict as br } from "@/i18n/br" +import { dict as th } from "@/i18n/th" +import { dict as bs } from "@/i18n/bs" +import { dict as tr } from "@/i18n/tr" +import { dict as uiEn } from "@opencode-ai/ui/i18n/en" +import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" +import { dict as uiZht } from "@opencode-ai/ui/i18n/zht" +import { dict as uiKo } from "@opencode-ai/ui/i18n/ko" +import { dict as uiDe } from "@opencode-ai/ui/i18n/de" +import { dict as uiEs } from "@opencode-ai/ui/i18n/es" +import { dict as uiFr } from "@opencode-ai/ui/i18n/fr" +import { dict as uiDa } from "@opencode-ai/ui/i18n/da" +import { dict as uiJa } from "@opencode-ai/ui/i18n/ja" +import { dict as uiPl } from "@opencode-ai/ui/i18n/pl" +import { dict as uiRu } from "@opencode-ai/ui/i18n/ru" +import { dict as uiAr } from "@opencode-ai/ui/i18n/ar" +import { dict as uiNo } from "@opencode-ai/ui/i18n/no" +import { dict as uiBr } from "@opencode-ai/ui/i18n/br" +import { dict as uiTh } from "@opencode-ai/ui/i18n/th" +import { dict as uiBs } from "@opencode-ai/ui/i18n/bs" +import { dict as uiTr } from "@opencode-ai/ui/i18n/tr" + +export type Locale = + | "en" + | "zh" + | "zht" + | "ko" + | "de" + | "es" + | "fr" + | "da" + | "ja" + | "pl" + | "ru" + | "ar" + | "no" + | "br" + | "th" + | "bs" + | "tr" + +type RawDictionary = typeof en & typeof uiEn +type Dictionary = i18n.Flatten + +function cookie(locale: Locale) { + return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` +} + +const LOCALES: readonly Locale[] = [ + "en", + "zh", + "zht", + "ko", + "de", + "es", + "fr", + "da", + "ja", + "pl", + "ru", + "bs", + "ar", + "no", + "br", + "th", + "tr", +] + +const INTL: Record = { + en: "en", + zh: "zh-Hans", + zht: "zh-Hant", + ko: "ko", + de: "de", + es: "es", + fr: "fr", + da: "da", + ja: "ja", + pl: "pl", + ru: "ru", + ar: "ar", + no: "nb-NO", + br: "pt-BR", + th: "th", + bs: "bs", + tr: "tr", +} + +const LABEL_KEY: Record = { + en: "language.en", + zh: "language.zh", + zht: "language.zht", + ko: "language.ko", + de: "language.de", + es: "language.es", + fr: "language.fr", + da: "language.da", + ja: "language.ja", + pl: "language.pl", + ru: "language.ru", + ar: "language.ar", + no: "language.no", + br: "language.br", + th: "language.th", + bs: "language.bs", + tr: "language.tr", +} + +const base = i18n.flatten({ ...en, ...uiEn }) +const DICT: Record = { + en: base, + zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, + zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, + ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }, + de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, + es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, + fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, + da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, + ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, + pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, + ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, + ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, + no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, + br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, + th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, + bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, + tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) }, +} + +const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ + { locale: "en", match: (language) => language.startsWith("en") }, + { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") }, + { locale: "zh", match: (language) => language.startsWith("zh") }, + { locale: "ko", match: (language) => language.startsWith("ko") }, + { locale: "de", match: (language) => language.startsWith("de") }, + { locale: "es", match: (language) => language.startsWith("es") }, + { locale: "fr", match: (language) => language.startsWith("fr") }, + { locale: "da", match: (language) => language.startsWith("da") }, + { locale: "ja", match: (language) => language.startsWith("ja") }, + { locale: "pl", match: (language) => language.startsWith("pl") }, + { locale: "ru", match: (language) => language.startsWith("ru") }, + { locale: "ar", match: (language) => language.startsWith("ar") }, + { + locale: "no", + match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"), + }, + { locale: "br", match: (language) => language.startsWith("pt") }, + { locale: "th", match: (language) => language.startsWith("th") }, + { locale: "bs", match: (language) => language.startsWith("bs") }, + { locale: "tr", match: (language) => language.startsWith("tr") }, +] + +type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" +const PARITY_CHECK: Record, Record> = { + zh, + zht, + ko, + de, + es, + fr, + da, + ja, + pl, + ru, + ar, + no, + br, + th, + bs, + tr, +} +void PARITY_CHECK + +function detectLocale(): Locale { + if (typeof navigator !== "object") return "en" + + const languages = navigator.languages?.length ? navigator.languages : [navigator.language] + for (const language of languages) { + if (!language) continue + const normalized = language.toLowerCase() + const match = localeMatchers.find((entry) => entry.match(normalized)) + if (match) return match.locale + } + + return "en" +} + +function normalizeLocale(value: string): Locale { + return LOCALES.includes(value as Locale) ? (value as Locale) : "en" +} + +export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ + name: "Language", + init: () => { + const [store, setStore, _, ready] = persisted( + Persist.global("language", ["language.v1"]), + createStore({ + locale: detectLocale() as Locale, + }), + ) + + const locale = createMemo(() => normalizeLocale(store.locale)) + console.log("locale", locale()) + const intl = createMemo(() => INTL[locale()]) + + const dict = createMemo(() => DICT[locale()]) + + const t = i18n.translator(dict, i18n.resolveTemplate) + + const label = (value: Locale) => t(LABEL_KEY[value]) + + createEffect(() => { + if (typeof document !== "object") return + document.documentElement.lang = locale() + document.cookie = cookie(locale()) + }) + + return { + ready, + locale, + intl, + locales: LOCALES, + label, + t, + setLocale(next: Locale) { + setStore("locale", normalizeLocale(next)) + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/layout-scroll.test.ts b/Agent-backend/packages/app/src/context/layout-scroll.test.ts new file mode 100644 index 000000000..483be150f --- /dev/null +++ b/Agent-backend/packages/app/src/context/layout-scroll.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test, vi } from "bun:test" +import { createScrollPersistence } from "./layout-scroll" + +describe("createScrollPersistence", () => { + test("debounces persisted scroll writes", () => { + vi.useFakeTimers() + try { + const snapshot = { + session: { + review: { x: 0, y: 0 }, + }, + } as Record> + const writes: Array> = [] + const scroll = createScrollPersistence({ + debounceMs: 10, + getSnapshot: (sessionKey) => snapshot[sessionKey], + onFlush: (sessionKey, next) => { + snapshot[sessionKey] = next + writes.push(next) + }, + }) + + for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) { + scroll.setScroll("session", "review", { x: 0, y: i }) + } + + vi.advanceTimersByTime(9) + expect(writes).toHaveLength(0) + + vi.advanceTimersByTime(1) + + expect(writes).toHaveLength(1) + expect(writes[0]?.review).toEqual({ x: 0, y: 30 }) + + scroll.setScroll("session", "review", { x: 0, y: 30 }) + vi.advanceTimersByTime(20) + + expect(writes).toHaveLength(1) + scroll.dispose() + } finally { + vi.useRealTimers() + } + }) + + test("reseeds empty cache after persisted snapshot loads", () => { + const snapshot = { + session: {}, + } as Record> + + const scroll = createScrollPersistence({ + getSnapshot: (sessionKey) => snapshot[sessionKey], + onFlush: () => {}, + }) + + expect(scroll.scroll("session", "review")).toBeUndefined() + + snapshot.session = { + review: { x: 12, y: 34 }, + } + + expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 }) + scroll.dispose() + }) +}) diff --git a/Agent-backend/packages/app/src/context/layout-scroll.ts b/Agent-backend/packages/app/src/context/layout-scroll.ts new file mode 100644 index 000000000..ef66eccd9 --- /dev/null +++ b/Agent-backend/packages/app/src/context/layout-scroll.ts @@ -0,0 +1,126 @@ +import { createStore, produce } from "solid-js/store" + +export type SessionScroll = { + x: number + y: number +} + +type ScrollMap = Record + +type Options = { + debounceMs?: number + getSnapshot: (sessionKey: string) => ScrollMap | undefined + onFlush: (sessionKey: string, scroll: ScrollMap) => void +} + +export function createScrollPersistence(opts: Options) { + const wait = opts.debounceMs ?? 200 + const [cache, setCache] = createStore>({}) + const dirty = new Set() + const timers = new Map>() + + function clone(input?: ScrollMap) { + const out: ScrollMap = {} + if (!input) return out + + for (const key of Object.keys(input)) { + const pos = input[key] + if (!pos) continue + out[key] = { x: pos.x, y: pos.y } + } + + return out + } + + function seed(sessionKey: string) { + const next = clone(opts.getSnapshot(sessionKey)) + const current = cache[sessionKey] + if (!current) { + setCache(sessionKey, next) + return + } + + if (Object.keys(current).length > 0) return + if (Object.keys(next).length === 0) return + setCache(sessionKey, next) + } + + function scroll(sessionKey: string, tab: string) { + seed(sessionKey) + return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab] + } + + function schedule(sessionKey: string) { + const prev = timers.get(sessionKey) + if (prev) clearTimeout(prev) + timers.set( + sessionKey, + setTimeout(() => flush(sessionKey), wait), + ) + } + + function setScroll(sessionKey: string, tab: string, pos: SessionScroll) { + seed(sessionKey) + + const prev = cache[sessionKey]?.[tab] + if (prev?.x === pos.x && prev?.y === pos.y) return + + setCache(sessionKey, tab, { x: pos.x, y: pos.y }) + dirty.add(sessionKey) + schedule(sessionKey) + } + + function flush(sessionKey: string) { + const timer = timers.get(sessionKey) + if (timer) clearTimeout(timer) + timers.delete(sessionKey) + + if (!dirty.has(sessionKey)) return + dirty.delete(sessionKey) + + opts.onFlush(sessionKey, clone(cache[sessionKey])) + } + + function flushAll() { + const keys = Array.from(dirty) + if (keys.length === 0) return + + for (const key of keys) { + flush(key) + } + } + + function drop(keys: string[]) { + if (keys.length === 0) return + + for (const key of keys) { + const timer = timers.get(key) + if (timer) clearTimeout(timer) + timers.delete(key) + dirty.delete(key) + } + + setCache( + produce((draft) => { + for (const key of keys) { + delete draft[key] + } + }), + ) + } + + function dispose() { + drop(Array.from(timers.keys())) + } + + return { + cache, + drop, + flush, + flushAll, + scroll, + seed, + setScroll, + dispose, + } +} diff --git a/Agent-backend/packages/app/src/context/layout.test.ts b/Agent-backend/packages/app/src/context/layout.test.ts new file mode 100644 index 000000000..582d5edbd --- /dev/null +++ b/Agent-backend/packages/app/src/context/layout.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout" + +describe("layout session-key helpers", () => { + test("couples touch and scroll seed in order", () => { + const calls: string[] = [] + const result = ensureSessionKey( + "dir/a", + (key) => calls.push(`touch:${key}`), + (key) => calls.push(`seed:${key}`), + ) + + expect(result).toBe("dir/a") + expect(calls).toEqual(["touch:dir/a", "seed:dir/a"]) + }) + + test("reads dynamic accessor keys lazily", () => { + const seen: string[] = [] + + createRoot((dispose) => { + const [key, setKey] = createSignal("dir/one") + const read = createSessionKeyReader(key, (value) => seen.push(value)) + + expect(read()).toBe("dir/one") + setKey("dir/two") + expect(read()).toBe("dir/two") + + dispose() + }) + + expect(seen).toEqual(["dir/one", "dir/two"]) + }) +}) + +describe("pruneSessionKeys", () => { + test("keeps active key and drops lowest-used keys", () => { + const drop = pruneSessionKeys({ + keep: "k4", + max: 3, + used: new Map([ + ["k1", 1], + ["k2", 2], + ["k3", 3], + ["k4", 4], + ]), + view: ["k1", "k2", "k4"], + tabs: ["k1", "k3", "k4"], + }) + + expect(drop).toEqual(["k1"]) + expect(drop.includes("k4")).toBe(false) + }) + + test("does not prune without keep key", () => { + const drop = pruneSessionKeys({ + keep: undefined, + max: 1, + used: new Map([ + ["k1", 1], + ["k2", 2], + ]), + view: ["k1"], + tabs: ["k2"], + }) + + expect(drop).toEqual([]) + }) +}) diff --git a/Agent-backend/packages/app/src/context/layout.tsx b/Agent-backend/packages/app/src/context/layout.tsx new file mode 100644 index 000000000..5199e5a26 --- /dev/null +++ b/Agent-backend/packages/app/src/context/layout.tsx @@ -0,0 +1,890 @@ +import { createStore, produce } from "solid-js/store" +import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useGlobalSync } from "./global-sync" +import { useGlobalSDK } from "./global-sdk" +import { useServer } from "./server" +import { usePlatform } from "./platform" +import { Project } from "@opencode-ai/sdk/v2" +import { Persist, persisted, removePersisted } from "@/utils/persist" +import { decode64 } from "@/utils/base64" +import { same } from "@/utils/same" +import { createScrollPersistence, type SessionScroll } from "./layout-scroll" +import { createPathHelpers } from "./file/path" + +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const +const DEFAULT_PANEL_WIDTH = 344 +const DEFAULT_SESSION_WIDTH = 600 +const DEFAULT_TERMINAL_HEIGHT = 280 +export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] + +export function getAvatarColors(key?: string) { + if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) { + return { + background: `var(--avatar-background-${key})`, + foreground: `var(--avatar-text-${key})`, + } + } + return { + background: "var(--surface-info-base)", + foreground: "var(--text-base)", + } +} + +type SessionTabs = { + active?: string + all: string[] +} + +type SessionView = { + scroll: Record + reviewOpen?: string[] + pendingMessage?: string + pendingMessageAt?: number +} + +type TabHandoff = { + dir: string + id: string + at: number +} + +export type LocalProject = Partial & { worktree: string; expanded: boolean } + +export type ReviewDiffStyle = "unified" | "split" + +export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { + touch(key) + seed(key) + return key +} + +export function createSessionKeyReader(sessionKey: string | Accessor, ensure: (key: string) => void) { + const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey + return () => { + const value = key() + ensure(value) + return value + } +} + +export function pruneSessionKeys(input: { + keep?: string + max: number + used: Map + view: string[] + tabs: string[] +}) { + if (!input.keep) return [] + + const keys = new Set([...input.view, ...input.tabs]) + if (keys.size <= input.max) return [] + + const score = (key: string) => { + if (key === input.keep) return Number.MAX_SAFE_INTEGER + return input.used.get(key) ?? 0 + } + + return Array.from(keys) + .sort((a, b) => score(b) - score(a)) + .slice(input.max) +} + +function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { + const all = current?.all ?? [] + if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab } + if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab } + if (!all.includes(tab)) return { all: [...all, tab], active: tab } + return { all, active: tab } +} + +const sessionPath = (key: string) => { + const dir = key.split("/")[0] + if (!dir) return + const root = decode64(dir) + if (!root) return + return createPathHelpers(() => root) +} + +const normalizeSessionTab = (path: ReturnType | undefined, tab: string) => { + if (!tab.startsWith("file://")) return tab + if (!path) return tab + return path.tab(tab) +} + +const normalizeSessionTabList = (path: ReturnType | undefined, all: string[]) => { + const seen = new Set() + return all.flatMap((tab) => { + const value = normalizeSessionTab(path, tab) + if (seen.has(value)) return [] + seen.add(value) + return [value] + }) +} + +const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { + const path = sessionPath(key) + return { + all: normalizeSessionTabList(path, tabs.all), + active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active, + } +} + +export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ + name: "Layout", + init: () => { + const globalSdk = useGlobalSDK() + const globalSync = useGlobalSync() + const server = useServer() + const platform = usePlatform() + + const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + + const migrate = (value: unknown) => { + if (!isRecord(value)) return value + + const sidebar = value.sidebar + const migratedSidebar = (() => { + if (!isRecord(sidebar)) return sidebar + if (typeof sidebar.workspaces !== "boolean") return sidebar + return { + ...sidebar, + workspaces: {}, + workspacesDefault: sidebar.workspaces, + } + })() + + const review = value.review + const fileTree = value.fileTree + const migratedFileTree = (() => { + if (!isRecord(fileTree)) return fileTree + if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree + + const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH + return { + ...fileTree, + opened: true, + width: width === 260 ? DEFAULT_PANEL_WIDTH : width, + tab: "changes", + } + })() + + const migratedReview = (() => { + if (!isRecord(review)) return review + if (typeof review.panelOpened === "boolean") return review + + const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true + return { + ...review, + panelOpened: opened, + } + })() + + const sessionTabs = value.sessionTabs + const migratedSessionTabs = (() => { + if (!isRecord(sessionTabs)) return sessionTabs + + let changed = false + const next = Object.fromEntries( + Object.entries(sessionTabs).map(([key, tabs]) => { + if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs] + + const current = { + all: tabs.all.filter((tab): tab is string => typeof tab === "string"), + active: typeof tabs.active === "string" ? tabs.active : undefined, + } + const normalized = normalizeStoredSessionTabs(key, current) + if (current.all.length !== tabs.all.length) changed = true + if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true + if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true + return [key, normalized] + }), + ) + + if (!changed) return sessionTabs + return next + })() + + if ( + migratedSidebar === sidebar && + migratedReview === review && + migratedFileTree === fileTree && + migratedSessionTabs === sessionTabs + ) { + return value + } + + return { + ...value, + sidebar: migratedSidebar, + review: migratedReview, + fileTree: migratedFileTree, + sessionTabs: migratedSessionTabs, + } + } + + const target = Persist.global("layout", ["layout.v6"]) + const [store, setStore, _, ready] = persisted( + { ...target, migrate }, + createStore({ + sidebar: { + opened: false, + width: DEFAULT_PANEL_WIDTH, + workspaces: {} as Record, + workspacesDefault: false, + }, + terminal: { + height: DEFAULT_TERMINAL_HEIGHT, + opened: false, + }, + review: { + diffStyle: "split" as ReviewDiffStyle, + panelOpened: true, + }, + fileTree: { + opened: true, + width: DEFAULT_PANEL_WIDTH, + tab: "changes" as "changes" | "all", + }, + session: { + width: DEFAULT_SESSION_WIDTH, + }, + mobileSidebar: { + opened: false, + }, + sessionTabs: {} as Record, + sessionView: {} as Record, + handoff: { + tabs: undefined as TabHandoff | undefined, + }, + }), + ) + + const MAX_SESSION_KEYS = 50 + const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 + const usage = { + active: undefined as string | undefined, + pruned: false, + used: new Map(), + } + + const SESSION_STATE_KEYS = [ + { key: "prompt", legacy: "prompt", version: "v2" }, + { key: "terminal", legacy: "terminal", version: "v1" }, + { key: "file-view", legacy: "file", version: "v1" }, + ] as const + + const dropSessionState = (keys: string[]) => { + for (const key of keys) { + const parts = key.split("/") + const dir = parts[0] + const session = parts[1] + if (!dir) continue + + for (const entry of SESSION_STATE_KEYS) { + const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key) + void removePersisted(target, platform) + + const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}` + void removePersisted({ key: legacyKey }, platform) + } + } + } + + function prune(keep?: string) { + const drop = pruneSessionKeys({ + keep, + max: MAX_SESSION_KEYS, + used: usage.used, + view: Object.keys(store.sessionView), + tabs: Object.keys(store.sessionTabs), + }) + if (drop.length === 0) return + + setStore( + produce((draft) => { + for (const key of drop) { + delete draft.sessionView[key] + delete draft.sessionTabs[key] + } + }), + ) + + scroll.drop(drop) + dropSessionState(drop) + + for (const key of drop) { + usage.used.delete(key) + } + } + + function touch(sessionKey: string) { + usage.active = sessionKey + usage.used.set(sessionKey, Date.now()) + + if (!ready()) return + if (usage.pruned) return + + usage.pruned = true + prune(sessionKey) + } + + const scroll = createScrollPersistence({ + debounceMs: 250, + getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, + onFlush: (sessionKey, next) => { + const current = store.sessionView[sessionKey] + const keep = usage.active ?? sessionKey + if (!current) { + setStore("sessionView", sessionKey, { scroll: next }) + prune(keep) + return + } + + setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next })) + prune(keep) + }, + }) + + const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey)) + + createEffect(() => { + if (!ready()) return + if (usage.pruned) return + const active = usage.active + if (!active) return + usage.pruned = true + prune(active) + }) + + onMount(() => { + const flush = () => batch(() => scroll.flushAll()) + const handleVisibility = () => { + if (document.visibilityState !== "hidden") return + flush() + } + + window.addEventListener("pagehide", flush) + document.addEventListener("visibilitychange", handleVisibility) + + onCleanup(() => { + window.removeEventListener("pagehide", flush) + document.removeEventListener("visibilitychange", handleVisibility) + scroll.dispose() + }) + }) + + const [colors, setColors] = createStore>({}) + const colorRequested = new Map() + + function pickAvailableColor(used: Set): AvatarColorKey { + const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c)) + if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)] + return available[Math.floor(Math.random() * available.length)] + } + + function enrich(project: { worktree: string; expanded: boolean }) { + const [childStore] = globalSync.child(project.worktree, { bootstrap: false }) + const projectID = childStore.project + const metadata = projectID + ? globalSync.data.project.find((x) => x.id === projectID) + : globalSync.data.project.find((x) => x.worktree === project.worktree) + + const local = childStore.projectMeta + const localOverride = + local?.name !== undefined || + local?.commands?.start !== undefined || + local?.icon?.override !== undefined || + local?.icon?.color !== undefined + + const base = { + ...(metadata ?? {}), + ...project, + icon: { + url: metadata?.icon?.url, + override: metadata?.icon?.override ?? childStore.icon, + color: metadata?.icon?.color, + }, + } + + const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride) + if (!isGlobal) return base + + return { + ...base, + id: base.id ?? "global", + name: local?.name, + commands: local?.commands, + icon: { + url: base.icon?.url, + override: local?.icon?.override, + color: local?.icon?.color, + }, + } + } + + const roots = createMemo(() => { + const map = new Map() + for (const project of globalSync.data.project) { + const sandboxes = project.sandboxes ?? [] + for (const sandbox of sandboxes) { + map.set(sandbox, project.worktree) + } + } + return map + }) + + const rootFor = (directory: string) => { + const map = roots() + if (map.size === 0) return directory + + const visited = new Set() + const chain = [directory] + + while (chain.length) { + const current = chain[chain.length - 1] + if (!current) return directory + + const next = map.get(current) + if (!next) return current + + if (visited.has(next)) return directory + visited.add(next) + chain.push(next) + } + + return directory + } + + createEffect(() => { + const projects = server.projects.list() + const seen = new Set(projects.map((project) => project.worktree)) + + batch(() => { + for (const project of projects) { + const root = rootFor(project.worktree) + if (root === project.worktree) continue + + server.projects.close(project.worktree) + + if (!seen.has(root)) { + server.projects.open(root) + seen.add(root) + } + + if (project.expanded) server.projects.expand(root) + } + }) + }) + + const enriched = createMemo(() => server.projects.list().map(enrich)) + const list = createMemo(() => { + const projects = enriched() + return projects.map((project) => { + const color = project.icon?.color ?? colors[project.worktree] + if (!color) return project + const icon = project.icon ? { ...project.icon, color } : { color } + return { ...project, icon } + }) + }) + + createEffect(() => { + const projects = enriched() + if (projects.length === 0) return + if (!globalSync.ready) return + + for (const project of projects) { + if (!project.id) continue + if (project.id === "global") continue + globalSync.project.icon(project.worktree, project.icon?.override) + } + }) + + createEffect(() => { + const projects = enriched() + if (projects.length === 0) return + + for (const project of projects) { + if (project.icon?.color) colorRequested.delete(project.worktree) + } + + const used = new Set() + for (const project of projects) { + const color = project.icon?.color ?? colors[project.worktree] + if (color) used.add(color) + } + + for (const project of projects) { + if (project.icon?.color) continue + const worktree = project.worktree + const existing = colors[worktree] + const color = existing ?? pickAvailableColor(used) + if (!existing) { + used.add(color) + setColors(worktree, color) + } + if (!project.id) continue + + const requested = colorRequested.get(worktree) + if (requested === color) continue + colorRequested.set(worktree, color) + + if (project.id === "global") { + globalSync.project.meta(worktree, { icon: { color } }) + continue + } + + void globalSdk.client.project + .update({ projectID: project.id, directory: worktree, icon: { color } }) + .catch(() => { + if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) + }) + } + }) + + onMount(() => { + Promise.all( + server.projects.list().map((project) => { + return globalSync.project.loadSessions(project.worktree) + }), + ) + }) + + return { + ready, + handoff: { + tabs: createMemo(() => store.handoff?.tabs), + setTabs(dir: string, id: string) { + setStore("handoff", "tabs", { dir, id, at: Date.now() }) + }, + clearTabs() { + if (!store.handoff?.tabs) return + setStore("handoff", "tabs", undefined) + }, + }, + projects: { + list, + open(directory: string) { + const root = rootFor(directory) + if (server.projects.list().find((x) => x.worktree === root)) return + globalSync.project.loadSessions(root) + server.projects.open(root) + }, + close(directory: string) { + server.projects.close(directory) + }, + expand(directory: string) { + server.projects.expand(directory) + }, + collapse(directory: string) { + server.projects.collapse(directory) + }, + move(directory: string, toIndex: number) { + server.projects.move(directory, toIndex) + }, + }, + sidebar: { + opened: createMemo(() => store.sidebar.opened), + open() { + setStore("sidebar", "opened", true) + }, + close() { + setStore("sidebar", "opened", false) + }, + toggle() { + setStore("sidebar", "opened", (x) => !x) + }, + width: createMemo(() => store.sidebar.width), + resize(width: number) { + setStore("sidebar", "width", width) + }, + workspaces(directory: string) { + return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false + }, + setWorkspaces(directory: string, value: boolean) { + setStore("sidebar", "workspaces", directory, value) + }, + toggleWorkspaces(directory: string) { + const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false + setStore("sidebar", "workspaces", directory, !current) + }, + }, + terminal: { + height: createMemo(() => store.terminal.height), + resize(height: number) { + setStore("terminal", "height", height) + }, + }, + review: { + diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), + setDiffStyle(diffStyle: ReviewDiffStyle) { + if (!store.review) { + setStore("review", { diffStyle, panelOpened: true }) + return + } + setStore("review", "diffStyle", diffStyle) + }, + }, + fileTree: { + opened: createMemo(() => store.fileTree?.opened ?? true), + width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH), + tab: createMemo(() => store.fileTree?.tab ?? "changes"), + setTab(tab: "changes" | "all") { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab }) + return + } + setStore("fileTree", "tab", tab) + }, + open() { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" }) + return + } + setStore("fileTree", "opened", true) + }, + close() { + if (!store.fileTree) { + setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" }) + return + } + setStore("fileTree", "opened", false) + }, + toggle() { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" }) + return + } + setStore("fileTree", "opened", (x) => !x) + }, + resize(width: number) { + if (!store.fileTree) { + setStore("fileTree", { opened: true, width, tab: "changes" }) + return + } + setStore("fileTree", "width", width) + }, + }, + session: { + width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH), + resize(width: number) { + if (!store.session) { + setStore("session", { width }) + return + } + setStore("session", "width", width) + }, + }, + mobileSidebar: { + opened: createMemo(() => store.mobileSidebar?.opened ?? false), + show() { + setStore("mobileSidebar", "opened", true) + }, + hide() { + setStore("mobileSidebar", "opened", false) + }, + toggle() { + setStore("mobileSidebar", "opened", (x) => !x) + }, + }, + pendingMessage: { + set(sessionKey: string, messageID: string) { + const at = Date.now() + touch(sessionKey) + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { + scroll: {}, + pendingMessage: messageID, + pendingMessageAt: at, + }) + prune(usage.active ?? sessionKey) + return + } + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + draft.pendingMessage = messageID + draft.pendingMessageAt = at + }), + ) + }, + consume(sessionKey: string) { + const current = store.sessionView[sessionKey] + const message = current?.pendingMessage + const at = current?.pendingMessageAt + if (!message || !at) return + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + delete draft.pendingMessage + delete draft.pendingMessageAt + }), + ) + + if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return + return message + }, + }, + view(sessionKey: string | Accessor) { + const key = createSessionKeyReader(sessionKey, ensureKey) + const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) + const terminalOpened = createMemo(() => store.terminal?.opened ?? false) + const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) + + function setTerminalOpened(next: boolean) { + const current = store.terminal + if (!current) { + setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next }) + return + } + + const value = current.opened ?? false + if (value === next) return + setStore("terminal", "opened", next) + } + + function setReviewPanelOpened(next: boolean) { + const current = store.review + if (!current) { + setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) + return + } + + const value = current.panelOpened ?? true + if (value === next) return + setStore("review", "panelOpened", next) + } + + return { + scroll(tab: string) { + return scroll.scroll(key(), tab) + }, + setScroll(tab: string, pos: SessionScroll) { + scroll.setScroll(key(), tab, pos) + }, + terminal: { + opened: terminalOpened, + open() { + setTerminalOpened(true) + }, + close() { + setTerminalOpened(false) + }, + toggle() { + setTerminalOpened(!terminalOpened()) + }, + }, + reviewPanel: { + opened: reviewPanelOpened, + open() { + setReviewPanelOpened(true) + }, + close() { + setReviewPanelOpened(false) + }, + toggle() { + setReviewPanelOpened(!reviewPanelOpened()) + }, + }, + review: { + open: createMemo(() => s().reviewOpen), + setOpen(open: string[]) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { + scroll: {}, + reviewOpen: open, + }) + return + } + + if (same(current.reviewOpen, open)) return + setStore("sessionView", session, "reviewOpen", open) + }, + }, + } + }, + tabs(sessionKey: string | Accessor) { + const key = createSessionKeyReader(sessionKey, ensureKey) + const path = createMemo(() => sessionPath(key())) + const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) + const normalize = (tab: string) => normalizeSessionTab(path(), tab) + const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all) + return { + tabs, + active: createMemo(() => tabs().active), + all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), + setActive(tab: string | undefined) { + const session = key() + const next = tab ? normalize(tab) : tab + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: [], active: next }) + } else { + setStore("sessionTabs", session, "active", next) + } + }, + setAll(all: string[]) { + const session = key() + const next = normalizeAll(all).filter((tab) => tab !== "review") + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: next, active: undefined }) + } else { + setStore("sessionTabs", session, "all", next) + } + }, + async open(tab: string) { + const session = key() + const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab)) + setStore("sessionTabs", session, next) + }, + close(tab: string) { + const session = key() + const current = store.sessionTabs[session] + if (!current) return + + if (tab === "review") { + if (current.active !== tab) return + setStore("sessionTabs", session, "active", current.all[0]) + return + } + + const all = current.all.filter((x) => x !== tab) + if (current.active !== tab) { + setStore("sessionTabs", session, "all", all) + return + } + + const index = current.all.findIndex((f) => f === tab) + const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0] + batch(() => { + setStore("sessionTabs", session, "all", all) + setStore("sessionTabs", session, "active", next) + }) + }, + move(tab: string, to: number) { + const session = key() + const current = store.sessionTabs[session] + if (!current) return + const index = current.all.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "sessionTabs", + session, + "all", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + }, + } + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/local.tsx b/Agent-backend/packages/app/src/context/local.tsx new file mode 100644 index 000000000..75d1334a5 --- /dev/null +++ b/Agent-backend/packages/app/src/context/local.tsx @@ -0,0 +1,252 @@ +import { createStore } from "solid-js/store" +import { batch, createMemo } from "solid-js" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useSDK } from "./sdk" +import { useSync } from "./sync" +import { base64Encode } from "@opencode-ai/util/encode" +import { useProviders } from "@/hooks/use-providers" +import { useModels } from "@/context/models" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" + +export type ModelKey = { providerID: string; modelID: string } + +export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ + name: "Local", + init: () => { + const sdk = useSDK() + const sync = useSync() + const providers = useProviders() + const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id))) + + function isModelValid(model: ModelKey) { + const provider = providers.all().find((x) => x.id === model.providerID) + return !!provider?.models[model.modelID] && connected().has(model.providerID) + } + + function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) { + for (const modelFn of modelFns) { + const model = modelFn() + if (!model) continue + if (isModelValid(model)) return model + } + } + + let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined + + const agent = (() => { + const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) + const models = useModels() + + const [store, setStore] = createStore<{ + current?: string + }>({ + current: list()[0]?.name, + }) + return { + list, + current() { + const available = list() + if (available.length === 0) return undefined + return available.find((x) => x.name === store.current) ?? available[0] + }, + set(name: string | undefined) { + const available = list() + if (available.length === 0) { + setStore("current", undefined) + return + } + const match = name ? available.find((x) => x.name === name) : undefined + const value = match ?? available[0] + if (!value) return + setStore("current", value.name) + if (!value.model) return + setModel({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + if (value.variant) + models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant) + }, + move(direction: 1 | -1) { + const available = list() + if (available.length === 0) { + setStore("current", undefined) + return + } + let next = available.findIndex((x) => x.name === store.current) + direction + if (next < 0) next = available.length - 1 + if (next >= available.length) next = 0 + const value = available[next] + if (!value) return + setStore("current", value.name) + if (!value.model) return + setModel({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + if (value.variant) + models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant) + }, + } + })() + + const model = (() => { + const models = useModels() + + const [ephemeral, setEphemeral] = createStore<{ + model: Record + }>({ + model: {}, + }) + + const resolveConfigured = () => { + if (!sync.data.config.model) return + const [providerID, modelID] = sync.data.config.model.split("/") + const key = { providerID, modelID } + if (isModelValid(key)) return key + } + + const resolveRecent = () => { + for (const item of models.recent.list()) { + if (isModelValid(item)) return item + } + } + + const resolveDefault = () => { + const defaults = providers.default() + for (const provider of providers.connected()) { + const configured = defaults[provider.id] + if (configured) { + const key = { providerID: provider.id, modelID: configured } + if (isModelValid(key)) return key + } + + const first = Object.values(provider.models)[0] + if (!first) continue + const key = { providerID: provider.id, modelID: first.id } + if (isModelValid(key)) return key + } + } + + const fallbackModel = createMemo(() => { + return resolveConfigured() ?? resolveRecent() ?? resolveDefault() + }) + + const current = createMemo(() => { + const a = agent.current() + if (!a) return undefined + const key = getFirstValidModel( + () => ephemeral.model[a.name], + () => a.model, + fallbackModel, + ) + if (!key) return undefined + return models.find(key) + }) + + const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean)) + + const cycle = (direction: 1 | -1) => { + const recentList = recent() + const currentModel = current() + if (!currentModel) return + + const index = recentList.findIndex( + (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id, + ) + if (index === -1) return + + let next = index + direction + if (next < 0) next = recentList.length - 1 + if (next >= recentList.length) next = 0 + + const val = recentList[next] + if (!val) return + + model.set({ + providerID: val.provider.id, + modelID: val.id, + }) + } + + const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => { + batch(() => { + const currentAgent = agent.current() + const next = model ?? fallbackModel() + if (currentAgent) setEphemeral("model", currentAgent.name, next) + if (model) models.setVisibility(model, true) + if (options?.recent && model) models.recent.push(model) + }) + } + + setModel = set + + return { + ready: models.ready, + current, + recent, + list: models.list, + cycle, + set, + visible(model: ModelKey) { + return models.visible(model) + }, + setVisibility(model: ModelKey, visible: boolean) { + models.setVisibility(model, visible) + }, + variant: { + configured() { + const a = agent.current() + const m = current() + if (!a || !m) return undefined + return getConfiguredAgentVariant({ + agent: { model: a.model, variant: a.variant }, + model: { providerID: m.provider.id, modelID: m.id, variants: m.variants }, + }) + }, + selected() { + const m = current() + if (!m) return undefined + return models.variant.get({ providerID: m.provider.id, modelID: m.id }) + }, + current() { + return resolveModelVariant({ + variants: this.list(), + selected: this.selected(), + configured: this.configured(), + }) + }, + list() { + const m = current() + if (!m) return [] + if (!m.variants) return [] + return Object.keys(m.variants) + }, + set(value: string | undefined) { + const m = current() + if (!m) return + models.variant.set({ providerID: m.provider.id, modelID: m.id }, value) + }, + cycle() { + const variants = this.list() + if (variants.length === 0) return + this.set( + cycleModelVariant({ + variants, + selected: this.selected(), + configured: this.configured(), + }), + ) + }, + }, + } + })() + + const result = { + slug: createMemo(() => base64Encode(sdk.directory)), + model, + agent, + } + return result + }, +}) diff --git a/Agent-backend/packages/app/src/context/model-variant.test.ts b/Agent-backend/packages/app/src/context/model-variant.test.ts new file mode 100644 index 000000000..01b149fd2 --- /dev/null +++ b/Agent-backend/packages/app/src/context/model-variant.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" + +describe("model variant", () => { + test("resolves configured agent variant when model matches", () => { + const value = getConfiguredAgentVariant({ + agent: { + model: { providerID: "openai", modelID: "gpt-5.2" }, + variant: "xhigh", + }, + model: { + providerID: "openai", + modelID: "gpt-5.2", + variants: { low: {}, high: {}, xhigh: {} }, + }, + }) + + expect(value).toBe("xhigh") + }) + + test("ignores configured variant when model does not match", () => { + const value = getConfiguredAgentVariant({ + agent: { + model: { providerID: "openai", modelID: "gpt-5.2" }, + variant: "xhigh", + }, + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4", + variants: { low: {}, high: {}, xhigh: {} }, + }, + }) + + expect(value).toBeUndefined() + }) + + test("prefers selected variant over configured variant", () => { + const value = resolveModelVariant({ + variants: ["low", "high", "xhigh"], + selected: "high", + configured: "xhigh", + }) + + expect(value).toBe("high") + }) + + test("cycles from configured variant to next", () => { + const value = cycleModelVariant({ + variants: ["low", "high", "xhigh"], + selected: undefined, + configured: "high", + }) + + expect(value).toBe("xhigh") + }) + + test("wraps from configured last variant to first", () => { + const value = cycleModelVariant({ + variants: ["low", "high", "xhigh"], + selected: undefined, + configured: "xhigh", + }) + + expect(value).toBe("low") + }) +}) diff --git a/Agent-backend/packages/app/src/context/model-variant.ts b/Agent-backend/packages/app/src/context/model-variant.ts new file mode 100644 index 000000000..6b7ae7256 --- /dev/null +++ b/Agent-backend/packages/app/src/context/model-variant.ts @@ -0,0 +1,50 @@ +type AgentModel = { + providerID: string + modelID: string +} + +type Agent = { + model?: AgentModel + variant?: string +} + +type Model = AgentModel & { + variants?: Record +} + +type VariantInput = { + variants: string[] + selected: string | undefined + configured: string | undefined +} + +export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) { + if (!input.agent?.variant) return undefined + if (!input.agent.model) return undefined + if (!input.model?.variants) return undefined + if (input.agent.model.providerID !== input.model.providerID) return undefined + if (input.agent.model.modelID !== input.model.modelID) return undefined + if (!(input.agent.variant in input.model.variants)) return undefined + return input.agent.variant +} + +export function resolveModelVariant(input: VariantInput) { + if (input.selected && input.variants.includes(input.selected)) return input.selected + if (input.configured && input.variants.includes(input.configured)) return input.configured + return undefined +} + +export function cycleModelVariant(input: VariantInput) { + if (input.variants.length === 0) return undefined + if (input.selected && input.variants.includes(input.selected)) { + const index = input.variants.indexOf(input.selected) + if (index === input.variants.length - 1) return undefined + return input.variants[index + 1] + } + if (input.configured && input.variants.includes(input.configured)) { + const index = input.variants.indexOf(input.configured) + if (index === input.variants.length - 1) return input.variants[0] + return input.variants[index + 1] + } + return input.variants[0] +} diff --git a/Agent-backend/packages/app/src/context/models.tsx b/Agent-backend/packages/app/src/context/models.tsx new file mode 100644 index 000000000..12ec8371a --- /dev/null +++ b/Agent-backend/packages/app/src/context/models.tsx @@ -0,0 +1,163 @@ +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { DateTime } from "luxon" +import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useProviders } from "@/hooks/use-providers" +import { Persist, persisted } from "@/utils/persist" + +export type ModelKey = { providerID: string; modelID: string } + +type Visibility = "show" | "hide" +type User = ModelKey & { visibility: Visibility; favorite?: boolean } +type Store = { + user: User[] + recent: ModelKey[] + variant?: Record +} + +const RECENT_LIMIT = 5 + +function modelKey(model: ModelKey) { + return `${model.providerID}:${model.modelID}` +} + +export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ + name: "Models", + init: () => { + const providers = useProviders() + + const [store, setStore, _, ready] = persisted( + Persist.global("model", ["model.v1"]), + createStore({ + user: [], + recent: [], + variant: {}, + }), + ) + + const available = createMemo(() => + providers.connected().flatMap((p) => + Object.values(p.models).map((m) => ({ + ...m, + provider: p, + })), + ), + ) + + const release = createMemo( + () => + new Map( + available().map((model) => { + const parsed = DateTime.fromISO(model.release_date) + return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const + }), + ), + ) + + const latest = createMemo(() => + pipe( + available(), + filter( + (x) => + Math.abs( + (release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid")) + .diffNow() + .as("months"), + ) < 6, + ), + groupBy((x) => x.provider.id), + mapValues((models) => + pipe( + models, + groupBy((x) => x.family), + values(), + (groups) => + groups.flatMap((g) => { + const first = firstBy(g, [(x) => x.release_date, "desc"]) + return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] + }), + ), + ), + values(), + flat(), + ), + ) + + const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x)))) + + const visibility = createMemo(() => { + const map = new Map() + for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility) + return map + }) + + const list = createMemo(() => + available().map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + latest: m.name.includes("(latest)"), + })), + ) + + const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID) + + function update(model: ModelKey, state: Visibility) { + const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) + if (index >= 0) { + setStore("user", index, (current) => ({ ...current, visibility: state })) + return + } + setStore("user", store.user.length, { ...model, visibility: state }) + } + + const visible = (model: ModelKey) => { + const key = modelKey(model) + const state = visibility().get(key) + if (state === "hide") return false + if (state === "show") return true + if (latestSet().has(key)) return true + const date = release().get(key) + if (!date?.isValid) return true + return false + } + + const setVisibility = (model: ModelKey, state: boolean) => { + update(model, state ? "show" : "hide") + } + + const push = (model: ModelKey) => { + const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`) + if (uniq.length > RECENT_LIMIT) uniq.pop() + setStore("recent", uniq) + } + + const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}` + const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)] + + const setVariant = (model: ModelKey, value: string | undefined) => { + const key = variantKey(model) + if (!store.variant) { + setStore("variant", { [key]: value }) + return + } + setStore("variant", key, value) + } + + return { + ready, + list, + find, + visible, + setVisibility, + recent: { + list: createMemo(() => store.recent), + push, + }, + variant: { + get: getVariant, + set: setVariant, + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/notification-index.ts b/Agent-backend/packages/app/src/context/notification-index.ts new file mode 100644 index 000000000..0b316e7ec --- /dev/null +++ b/Agent-backend/packages/app/src/context/notification-index.ts @@ -0,0 +1,66 @@ +type NotificationIndexItem = { + directory?: string + session?: string + viewed: boolean + type: string +} + +export function buildNotificationIndex(list: T[]) { + const sessionAll = new Map() + const sessionUnseen = new Map() + const sessionUnseenCount = new Map() + const sessionUnseenHasError = new Map() + const projectAll = new Map() + const projectUnseen = new Map() + const projectUnseenCount = new Map() + const projectUnseenHasError = new Map() + + for (const notification of list) { + const session = notification.session + if (session) { + const all = sessionAll.get(session) + if (all) all.push(notification) + else sessionAll.set(session, [notification]) + + if (!notification.viewed) { + const unseen = sessionUnseen.get(session) + if (unseen) unseen.push(notification) + else sessionUnseen.set(session, [notification]) + + sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1) + if (notification.type === "error") sessionUnseenHasError.set(session, true) + } + } + + const directory = notification.directory + if (directory) { + const all = projectAll.get(directory) + if (all) all.push(notification) + else projectAll.set(directory, [notification]) + + if (!notification.viewed) { + const unseen = projectUnseen.get(directory) + if (unseen) unseen.push(notification) + else projectUnseen.set(directory, [notification]) + + projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1) + if (notification.type === "error") projectUnseenHasError.set(directory, true) + } + } + } + + return { + session: { + all: sessionAll, + unseen: sessionUnseen, + unseenCount: sessionUnseenCount, + unseenHasError: sessionUnseenHasError, + }, + project: { + all: projectAll, + unseen: projectUnseen, + unseenCount: projectUnseenCount, + unseenHasError: projectUnseenHasError, + }, + } +} diff --git a/Agent-backend/packages/app/src/context/notification.test.ts b/Agent-backend/packages/app/src/context/notification.test.ts new file mode 100644 index 000000000..44bacb704 --- /dev/null +++ b/Agent-backend/packages/app/src/context/notification.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { buildNotificationIndex } from "./notification-index" + +type Notification = { + type: "turn-complete" | "error" + session: string + directory: string + viewed: boolean + time: number +} + +const turn = (session: string, directory: string, viewed = false): Notification => ({ + type: "turn-complete", + session, + directory, + viewed, + time: 1, +}) + +const error = (session: string, directory: string, viewed = false): Notification => ({ + type: "error", + session, + directory, + viewed, + time: 1, +}) + +describe("buildNotificationIndex", () => { + test("builds unseen counts and unseen error flags", () => { + const list = [ + turn("s1", "d1", false), + error("s1", "d1", false), + turn("s1", "d1", true), + turn("s2", "d1", false), + error("s3", "d2", true), + ] + + const index = buildNotificationIndex(list) + + expect(index.session.all.get("s1")?.length).toBe(3) + expect(index.session.unseen.get("s1")?.length).toBe(2) + expect(index.session.unseenCount.get("s1")).toBe(2) + expect(index.session.unseenHasError.get("s1")).toBe(true) + + expect(index.session.unseenCount.get("s2")).toBe(1) + expect(index.session.unseenHasError.get("s2") ?? false).toBe(false) + expect(index.session.unseenCount.get("s3") ?? 0).toBe(0) + expect(index.session.unseenHasError.get("s3") ?? false).toBe(false) + + expect(index.project.unseenCount.get("d1")).toBe(3) + expect(index.project.unseenHasError.get("d1")).toBe(true) + expect(index.project.unseenCount.get("d2") ?? 0).toBe(0) + expect(index.project.unseenHasError.get("d2") ?? false).toBe(false) + }) + + test("updates selectors after viewed transitions", () => { + const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)] + const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item)) + + const before = buildNotificationIndex(list) + const after = buildNotificationIndex(next) + + expect(before.session.unseenCount.get("s1")).toBe(2) + expect(before.session.unseenHasError.get("s1")).toBe(true) + expect(before.project.unseenCount.get("d1")).toBe(3) + expect(before.project.unseenHasError.get("d1")).toBe(true) + + expect(after.session.unseenCount.get("s1") ?? 0).toBe(0) + expect(after.session.unseenHasError.get("s1") ?? false).toBe(false) + expect(after.project.unseenCount.get("d1")).toBe(1) + expect(after.project.unseenHasError.get("d1") ?? false).toBe(false) + }) +}) diff --git a/Agent-backend/packages/app/src/context/notification.tsx b/Agent-backend/packages/app/src/context/notification.tsx new file mode 100644 index 000000000..04bc2fdaa --- /dev/null +++ b/Agent-backend/packages/app/src/context/notification.tsx @@ -0,0 +1,373 @@ +import { createStore, reconcile } from "solid-js/store" +import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { useParams } from "@solidjs/router" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useGlobalSDK } from "./global-sdk" +import { useGlobalSync } from "./global-sync" +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" +import { Binary } from "@opencode-ai/util/binary" +import { base64Encode } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" +import { EventSessionError } from "@opencode-ai/sdk/v2" +import { Persist, persisted } from "@/utils/persist" +import { playSound, soundSrc } from "@/utils/sound" + +type NotificationBase = { + directory?: string + session?: string + metadata?: unknown + time: number + viewed: boolean +} + +type TurnCompleteNotification = NotificationBase & { + type: "turn-complete" +} + +type ErrorNotification = NotificationBase & { + type: "error" + error: EventSessionError["properties"]["error"] +} + +export type Notification = TurnCompleteNotification | ErrorNotification + +type NotificationIndex = { + session: { + all: Record + unseen: Record + unseenCount: Record + unseenHasError: Record + } + project: { + all: Record + unseen: Record + unseenCount: Record + unseenHasError: Record + } +} + +const MAX_NOTIFICATIONS = 500 +const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30 + +function pruneNotifications(list: Notification[]) { + const cutoff = Date.now() - NOTIFICATION_TTL_MS + const pruned = list.filter((n) => n.time >= cutoff) + if (pruned.length <= MAX_NOTIFICATIONS) return pruned + return pruned.slice(pruned.length - MAX_NOTIFICATIONS) +} + +function createNotificationIndex(): NotificationIndex { + return { + session: { + all: {}, + unseen: {}, + unseenCount: {}, + unseenHasError: {}, + }, + project: { + all: {}, + unseen: {}, + unseenCount: {}, + unseenHasError: {}, + }, + } +} + +function buildNotificationIndex(list: Notification[]) { + const index = createNotificationIndex() + + list.forEach((notification) => { + if (notification.session) { + const all = index.session.all[notification.session] ?? [] + index.session.all[notification.session] = [...all, notification] + if (!notification.viewed) { + const unseen = index.session.unseen[notification.session] ?? [] + index.session.unseen[notification.session] = [...unseen, notification] + index.session.unseenCount[notification.session] = unseen.length + 1 + if (notification.type === "error") index.session.unseenHasError[notification.session] = true + } + } + + if (notification.directory) { + const all = index.project.all[notification.directory] ?? [] + index.project.all[notification.directory] = [...all, notification] + if (!notification.viewed) { + const unseen = index.project.unseen[notification.directory] ?? [] + index.project.unseen[notification.directory] = [...unseen, notification] + index.project.unseenCount[notification.directory] = unseen.length + 1 + if (notification.type === "error") index.project.unseenHasError[notification.directory] = true + } + } + }) + + return index +} + +export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ + name: "Notification", + init: () => { + const params = useParams() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const platform = usePlatform() + const settings = useSettings() + const language = useLanguage() + + const empty: Notification[] = [] + + const currentDirectory = createMemo(() => { + return decode64(params.dir) + }) + + const currentSession = createMemo(() => params.id) + + const [store, setStore, _, ready] = persisted( + Persist.global("notification", ["notification.v1"]), + createStore({ + list: [] as Notification[], + }), + ) + const [index, setIndex] = createStore(buildNotificationIndex(store.list)) + + const meta = { pruned: false, disposed: false } + + const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => { + setIndex(scope, "unseen", key, unseen) + setIndex(scope, "unseenCount", key, unseen.length) + setIndex( + scope, + "unseenHasError", + key, + unseen.some((notification) => notification.type === "error"), + ) + } + + const appendToIndex = (notification: Notification) => { + if (notification.session) { + setIndex("session", "all", notification.session, (all = []) => [...all, notification]) + if (!notification.viewed) { + setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification]) + setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1) + if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true) + } + } + + if (notification.directory) { + setIndex("project", "all", notification.directory, (all = []) => [...all, notification]) + if (!notification.viewed) { + setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification]) + setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1) + if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true) + } + } + } + + const removeFromIndex = (notification: Notification) => { + if (notification.session) { + setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification)) + if (!notification.viewed) { + const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification) + updateUnseen("session", notification.session, unseen) + } + } + + if (notification.directory) { + setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification)) + if (!notification.viewed) { + const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification) + updateUnseen("project", notification.directory, unseen) + } + } + } + + createEffect(() => { + if (!ready()) return + if (meta.pruned) return + meta.pruned = true + const list = pruneNotifications(store.list) + batch(() => { + setStore("list", list) + setIndex(reconcile(buildNotificationIndex(list), { merge: false })) + }) + }) + + const append = (notification: Notification) => { + const list = pruneNotifications([...store.list, notification]) + const keep = new Set(list) + const removed = store.list.filter((n) => !keep.has(n)) + + batch(() => { + if (keep.has(notification)) appendToIndex(notification) + removed.forEach((n) => removeFromIndex(n)) + setStore("list", list) + }) + } + + const lookup = async (directory: string, sessionID?: string) => { + if (!sessionID) return undefined + const [syncStore] = globalSync.child(directory, { bootstrap: false }) + const match = Binary.search(syncStore.session, sessionID, (s) => s.id) + if (match.found) return syncStore.session[match.index] + return globalSDK.client.session + .get({ directory, sessionID }) + .then((x) => x.data) + .catch(() => undefined) + } + + const viewedInCurrentSession = (directory: string, sessionID?: string) => { + const activeDirectory = currentDirectory() + const activeSession = currentSession() + if (!activeDirectory) return false + if (!activeSession) return false + if (!sessionID) return false + if (directory !== activeDirectory) return false + return sessionID === activeSession + } + + const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { + const sessionID = event.properties.sessionID + void lookup(directory, sessionID).then((session) => { + if (meta.disposed) return + if (!session) return + if (session.parentID) return + + if (settings.sounds.agentEnabled()) { + playSound(soundSrc(settings.sounds.agent())) + } + + append({ + directory, + time, + viewed: viewedInCurrentSession(directory, sessionID), + type: "turn-complete", + session: sessionID, + }) + + const href = `/${base64Encode(directory)}/session/${sessionID}` + if (settings.notifications.agent()) { + void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href) + } + }) + } + + const handleSessionError = ( + directory: string, + event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, + time: number, + ) => { + const sessionID = event.properties.sessionID + void lookup(directory, sessionID).then((session) => { + if (meta.disposed) return + if (session?.parentID) return + + if (settings.sounds.errorsEnabled()) { + playSound(soundSrc(settings.sounds.errors())) + } + + const error = "error" in event.properties ? event.properties.error : undefined + append({ + directory, + time, + viewed: viewedInCurrentSession(directory, sessionID), + type: "error", + session: sessionID ?? "global", + error, + }) + const description = + session?.title ?? + (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) + const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` + if (settings.notifications.errors()) { + void platform.notify(language.t("notification.session.error.title"), description, href) + } + }) + } + + const unsub = globalSDK.event.listen((e) => { + const event = e.details + if (event.type !== "session.idle" && event.type !== "session.error") return + + const directory = e.name + const time = Date.now() + if (event.type === "session.idle") { + handleSessionIdle(directory, event, time) + return + } + handleSessionError(directory, event, time) + }) + onCleanup(() => { + meta.disposed = true + unsub() + }) + + return { + ready, + session: { + all(session: string) { + return index.session.all[session] ?? empty + }, + unseen(session: string) { + return index.session.unseen[session] ?? empty + }, + unseenCount(session: string) { + return index.session.unseenCount[session] ?? 0 + }, + unseenHasError(session: string) { + return index.session.unseenHasError[session] ?? false + }, + markViewed(session: string) { + const unseen = index.session.unseen[session] ?? empty + if (!unseen.length) return + + const projects = [ + ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))), + ] + batch(() => { + setStore("list", (n) => n.session === session && !n.viewed, "viewed", true) + updateUnseen("session", session, []) + projects.forEach((directory) => { + const next = (index.project.unseen[directory] ?? empty).filter( + (notification) => notification.session !== session, + ) + updateUnseen("project", directory, next) + }) + }) + }, + }, + project: { + all(directory: string) { + return index.project.all[directory] ?? empty + }, + unseen(directory: string) { + return index.project.unseen[directory] ?? empty + }, + unseenCount(directory: string) { + return index.project.unseenCount[directory] ?? 0 + }, + unseenHasError(directory: string) { + return index.project.unseenHasError[directory] ?? false + }, + markViewed(directory: string) { + const unseen = index.project.unseen[directory] ?? empty + if (!unseen.length) return + + const sessions = [ + ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))), + ] + batch(() => { + setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true) + updateUnseen("project", directory, []) + sessions.forEach((session) => { + const next = (index.session.unseen[session] ?? empty).filter( + (notification) => notification.directory !== directory, + ) + updateUnseen("session", session, next) + }) + }) + }, + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/permission-auto-respond.test.ts b/Agent-backend/packages/app/src/context/permission-auto-respond.test.ts new file mode 100644 index 000000000..755611300 --- /dev/null +++ b/Agent-backend/packages/app/src/context/permission-auto-respond.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/util/encode" +import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" + +const session = (input: { id: string; parentID?: string }) => + ({ + id: input.id, + parentID: input.parentID, + }) as Session + +const permission = (sessionID: string) => + ({ + sessionID, + }) as Pick + +describe("autoRespondsPermission", () => { + test("uses a parent session's directory-scoped auto-accept", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/root`]: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) + }) + + test("uses a parent session's legacy auto-accept key", () => { + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + + expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true) + }) + + test("defaults to requiring approval when no lineage override exists", () => { + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })] + const autoAccept = { + other: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false) + }) + + test("inherits a parent session's false override", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/root`]: false, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false) + }) + + test("prefers a child override over parent override", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/root`]: false, + [`${base64Encode(directory)}/child`]: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) + }) + + test("falls back to directory-level auto-accept", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/*`]: true, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true) + }) + + test("session-level override takes precedence over directory-level", () => { + const directory = "/tmp/project" + const sessions = [session({ id: "root" })] + const autoAccept = { + [`${base64Encode(directory)}/*`]: true, + [`${base64Encode(directory)}/root`]: false, + } + + expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) + }) +}) + +describe("isDirectoryAutoAccepting", () => { + test("returns true when directory key is set", () => { + const directory = "/tmp/project" + const autoAccept = { [`${base64Encode(directory)}/*`]: true } + expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true) + }) + + test("returns false when directory key is not set", () => { + expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false) + }) + + test("returns false when directory key is explicitly false", () => { + const directory = "/tmp/project" + const autoAccept = { [`${base64Encode(directory)}/*`]: false } + expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false) + }) +}) diff --git a/Agent-backend/packages/app/src/context/permission-auto-respond.ts b/Agent-backend/packages/app/src/context/permission-auto-respond.ts new file mode 100644 index 000000000..b206deedf --- /dev/null +++ b/Agent-backend/packages/app/src/context/permission-auto-respond.ts @@ -0,0 +1,51 @@ +import { base64Encode } from "@opencode-ai/util/encode" + +export function acceptKey(sessionID: string, directory?: string) { + if (!directory) return sessionID + return `${base64Encode(directory)}/${sessionID}` +} + +export function directoryAcceptKey(directory: string) { + return `${base64Encode(directory)}/*` +} + +function accepted(autoAccept: Record, sessionID: string, directory?: string) { + const key = acceptKey(sessionID, directory) + const directoryKey = directory ? directoryAcceptKey(directory) : undefined + return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined) +} + +export function isDirectoryAutoAccepting(autoAccept: Record, directory: string) { + const key = directoryAcceptKey(directory) + return autoAccept[key] ?? false +} + +function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { + const parent = session.reduce((acc, item) => { + if (item.parentID) acc.set(item.id, item.parentID) + return acc + }, new Map()) + const seen = new Set([sessionID]) + const ids = [sessionID] + + for (const id of ids) { + const parentID = parent.get(id) + if (!parentID || seen.has(parentID)) continue + seen.add(parentID) + ids.push(parentID) + } + + return ids +} + +export function autoRespondsPermission( + autoAccept: Record, + session: { id: string; parentID?: string }[], + permission: { sessionID: string }, + directory?: string, +) { + const value = sessionLineage(session, permission.sessionID) + .map((id) => accepted(autoAccept, id, directory)) + .find((item): item is boolean => item !== undefined) + return value ?? false +} diff --git a/Agent-backend/packages/app/src/context/permission.tsx b/Agent-backend/packages/app/src/context/permission.tsx new file mode 100644 index 000000000..672f84f82 --- /dev/null +++ b/Agent-backend/packages/app/src/context/permission.tsx @@ -0,0 +1,277 @@ +import { createEffect, createMemo, onCleanup } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" +import { Persist, persisted } from "@/utils/persist" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "./global-sync" +import { useParams } from "@solidjs/router" +import { decode64 } from "@/utils/base64" +import { + acceptKey, + directoryAcceptKey, + isDirectoryAutoAccepting, + autoRespondsPermission, +} from "./permission-auto-respond" + +type PermissionRespondFn = (input: { + sessionID: string + permissionID: string + response: "once" | "always" | "reject" + directory?: string +}) => void + +function isNonAllowRule(rule: unknown) { + if (!rule) return false + if (typeof rule === "string") return rule !== "allow" + if (typeof rule !== "object") return false + if (Array.isArray(rule)) return false + + for (const action of Object.values(rule)) { + if (action !== "allow") return true + } + + return false +} + +function hasPermissionPromptRules(permission: unknown) { + if (!permission) return false + if (typeof permission === "string") return permission !== "allow" + if (typeof permission !== "object") return false + if (Array.isArray(permission)) return false + + const config = permission as Record + return Object.values(config).some(isNonAllowRule) +} + +export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ + name: "Permission", + init: () => { + const params = useParams() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + + const permissionsEnabled = createMemo(() => { + const directory = decode64(params.dir) + if (!directory) return false + const [store] = globalSync.child(directory) + return hasPermissionPromptRules(store.config.permission) + }) + + const [store, setStore, _, ready] = persisted( + { + ...Persist.global("permission", ["permission.v3"]), + migrate(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value + + const data = value as Record + if (data.autoAccept) return value + + return { + ...data, + autoAccept: + typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits) + ? data.autoAcceptEdits + : {}, + } + }, + }, + createStore({ + autoAccept: {} as Record, + }), + ) + + // When config has permission: "allow", auto-enable directory-level auto-accept + createEffect(() => { + if (!ready()) return + const directory = decode64(params.dir) + if (!directory) return + const [childStore] = globalSync.child(directory) + const perm = childStore.config.permission + if (typeof perm === "string" && perm === "allow") { + const key = directoryAcceptKey(directory) + if (store.autoAccept[key] === undefined) { + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + } + } + }) + + const MAX_RESPONDED = 1000 + const RESPONDED_TTL_MS = 60 * 60 * 1000 + const responded = new Map() + const enableVersion = new Map() + + function pruneResponded(now: number) { + for (const [id, ts] of responded) { + if (now - ts < RESPONDED_TTL_MS) break + responded.delete(id) + } + + for (const id of responded.keys()) { + if (responded.size <= MAX_RESPONDED) break + responded.delete(id) + } + } + + const respond: PermissionRespondFn = (input) => { + globalSDK.client.permission.respond(input).catch(() => { + responded.delete(input.permissionID) + }) + } + + function respondOnce(permission: PermissionRequest, directory?: string) { + const now = Date.now() + const hit = responded.has(permission.id) + responded.delete(permission.id) + responded.set(permission.id, now) + pruneResponded(now) + if (hit) return + respond({ + sessionID: permission.sessionID, + permissionID: permission.id, + response: "once", + directory, + }) + } + + function isAutoAccepting(sessionID: string, directory?: string) { + const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : [] + return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory) + } + + function isAutoAcceptingDirectory(directory: string) { + return isDirectoryAutoAccepting(store.autoAccept, directory) + } + + function shouldAutoRespond(permission: PermissionRequest, directory?: string) { + const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : [] + return autoRespondsPermission(store.autoAccept, session, permission, directory) + } + + function bumpEnableVersion(sessionID: string, directory?: string) { + const key = acceptKey(sessionID, directory) + const next = (enableVersion.get(key) ?? 0) + 1 + enableVersion.set(key, next) + return next + } + + const unsubscribe = globalSDK.event.listen((e) => { + const event = e.details + if (event?.type !== "permission.asked") return + + const perm = event.properties + if (!shouldAutoRespond(perm, e.name)) return + + respondOnce(perm, e.name) + }) + onCleanup(unsubscribe) + + function enableDirectory(directory: string) { + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + + globalSDK.client.permission + .list({ directory }) + .then((x) => { + if (!isAutoAcceptingDirectory(directory)) return + for (const perm of x.data ?? []) { + if (!perm?.id) continue + if (!shouldAutoRespond(perm, directory)) continue + respondOnce(perm, directory) + } + }) + .catch(() => undefined) + } + + function disableDirectory(directory: string) { + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = false + }), + ) + } + + function enable(sessionID: string, directory: string) { + const key = acceptKey(sessionID, directory) + const version = bumpEnableVersion(sessionID, directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = true + delete draft.autoAccept[sessionID] + }), + ) + + globalSDK.client.permission + .list({ directory }) + .then((x) => { + if (enableVersion.get(key) !== version) return + if (!isAutoAccepting(sessionID, directory)) return + for (const perm of x.data ?? []) { + if (!perm?.id) continue + if (!shouldAutoRespond(perm, directory)) continue + respondOnce(perm, directory) + } + }) + .catch(() => undefined) + } + + function disable(sessionID: string, directory?: string) { + bumpEnableVersion(sessionID, directory) + const key = directory ? acceptKey(sessionID, directory) : sessionID + setStore( + produce((draft) => { + draft.autoAccept[key] = false + if (!directory) return + delete draft.autoAccept[sessionID] + }), + ) + } + + return { + ready, + respond, + autoResponds(permission: PermissionRequest, directory?: string) { + return shouldAutoRespond(permission, directory) + }, + isAutoAccepting, + isAutoAcceptingDirectory, + toggleAutoAccept(sessionID: string, directory: string) { + if (isAutoAccepting(sessionID, directory)) { + disable(sessionID, directory) + return + } + + enable(sessionID, directory) + }, + toggleAutoAcceptDirectory(directory: string) { + if (isAutoAcceptingDirectory(directory)) { + disableDirectory(directory) + return + } + enableDirectory(directory) + }, + enableAutoAccept(sessionID: string, directory: string) { + if (isAutoAccepting(sessionID, directory)) return + enable(sessionID, directory) + }, + disableAutoAccept(sessionID: string, directory?: string) { + disable(sessionID, directory) + }, + permissionsEnabled, + isPermissionAllowAll(directory: string) { + const [childStore] = globalSync.child(directory) + const perm = childStore.config.permission + return typeof perm === "string" && perm === "allow" + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/platform.tsx b/Agent-backend/packages/app/src/context/platform.tsx new file mode 100644 index 000000000..86f3321e4 --- /dev/null +++ b/Agent-backend/packages/app/src/context/platform.tsx @@ -0,0 +1,98 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage" +import type { Accessor } from "solid-js" + +type PickerPaths = string | string[] | null +type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } +type OpenFilePickerOptions = { title?: string; multiple?: boolean } +type SaveFilePickerOptions = { title?: string; defaultPath?: string } +type UpdateInfo = { updateAvailable: boolean; version?: string } + +export type Platform = { + /** Platform discriminator */ + platform: "web" | "desktop" + + /** Desktop OS (Tauri only) */ + os?: "macos" | "windows" | "linux" + + /** App version */ + version?: string + + /** Open a URL in the default browser */ + openLink(url: string): void + + /** Open a local path in a local app (desktop only) */ + openPath?(path: string, app?: string): Promise + + /** Restart the app */ + restart(): Promise + + /** Navigate back in history */ + back(): void + + /** Navigate forward in history */ + forward(): void + + /** Send a system notification (optional deep link) */ + notify(title: string, description?: string, href?: string): Promise + + /** Open directory picker dialog (native on Tauri, server-backed on web) */ + openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise + + /** Open native file picker dialog (Tauri only) */ + openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise + + /** Save file picker dialog (Tauri only) */ + saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise + + /** Storage mechanism, defaults to localStorage */ + storage?: (name?: string) => SyncStorage | AsyncStorage + + /** Check for updates (Tauri only) */ + checkUpdate?(): Promise + + /** Install updates (Tauri only) */ + update?(): Promise + + /** Fetch override */ + fetch?: typeof fetch + + /** Get the configured default server URL (platform-specific) */ + getDefaultServerUrl?(): Promise + + /** Set the default server URL to use on app startup (platform-specific) */ + setDefaultServerUrl?(url: string | null): Promise | void + + /** Get the configured WSL integration (desktop only) */ + getWslEnabled?(): Promise + + /** Set the configured WSL integration (desktop only) */ + setWslEnabled?(config: boolean): Promise | void + + /** Get the preferred display backend (desktop only) */ + getDisplayBackend?(): Promise | DisplayBackend | null + + /** Set the preferred display backend (desktop only) */ + setDisplayBackend?(backend: DisplayBackend): Promise + + /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ + parseMarkdown?(markdown: string): Promise + + /** Webview zoom level (desktop only) */ + webviewZoom?: Accessor + + /** Check if an editor app exists (desktop only) */ + checkAppExists?(appName: string): Promise + + /** Read image from clipboard (desktop only) */ + readClipboardImage?(): Promise +} + +export type DisplayBackend = "auto" | "wayland" + +export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ + name: "Platform", + init: (props: { value: Platform }) => { + return props.value + }, +}) diff --git a/Agent-backend/packages/app/src/context/prompt.tsx b/Agent-backend/packages/app/src/context/prompt.tsx new file mode 100644 index 000000000..fb8226559 --- /dev/null +++ b/Agent-backend/packages/app/src/context/prompt.tsx @@ -0,0 +1,287 @@ +import { createStore, type SetStoreFunction } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo, createRoot, onCleanup } from "solid-js" +import { useParams } from "@solidjs/router" +import type { FileSelection } from "@/context/file" +import { Persist, persisted } from "@/utils/persist" +import { checksum } from "@opencode-ai/util/encode" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: FileSelection +} + +export interface AgentPart extends PartBase { + type: "agent" + name: string +} + +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart +export type Prompt = ContentPart[] + +export type FileContextItem = { + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export type ContextItem = FileContextItem + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +function isSelectionEqual(a?: FileSelection, b?: FileSelection) { + if (!a && !b) return true + if (!a || !b) return false + return ( + a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar + ) +} + +function isPartEqual(partA: ContentPart, partB: ContentPart) { + switch (partA.type) { + case "text": + return partB.type === "text" && partA.content === partB.content + case "file": + return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection) + case "agent": + return partB.type === "agent" && partA.name === partB.name + case "image": + return partB.type === "image" && partA.id === partB.id + } +} + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + if (!isPartEqual(promptA[i], promptB[i])) return false + } + return true +} + +function cloneSelection(selection?: FileSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +function contextItemKey(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + const key = `${item.type}:${item.path}:${start}:${end}` + + if (item.commentID) { + return `${key}:c=${item.commentID}` + } + + const comment = item.comment?.trim() + if (!comment) return key + const digest = checksum(comment) ?? comment + return `${key}:c=${digest.slice(0, 8)}` +} + +function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { + return item.type === "file" && !!item.comment?.trim() +} + +function createPromptActions( + setStore: SetStoreFunction<{ + prompt: Prompt + cursor?: number + context: { + items: (ContextItem & { key: string })[] + } + }>, +) { + return { + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } +} + +const WORKSPACE_KEY = "__workspace__" +const MAX_PROMPT_SESSIONS = 20 + +type PromptSession = ReturnType + +type PromptCacheEntry = { + value: PromptSession + dispose: VoidFunction +} + +function createPromptSession(dir: string, id: string | undefined) { + const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2` + + const [store, setStore, _, ready] = persisted( + Persist.scoped(dir, id, "prompt", [legacy]), + createStore<{ + prompt: Prompt + cursor?: number + context: { + items: (ContextItem & { key: string })[] + } + }>({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + context: { + items: [], + }, + }), + ) + + const actions = createPromptActions(setStore) + + return { + ready, + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursor), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + context: { + items: createMemo(() => store.context.items), + add(item: ContextItem) { + const key = contextItemKey(item) + if (store.context.items.find((x) => x.key === key)) return + setStore("context", "items", (items) => [...items, { key, ...item }]) + }, + remove(key: string) { + setStore("context", "items", (items) => items.filter((x) => x.key !== key)) + }, + removeComment(path: string, commentID: string) { + setStore("context", "items", (items) => + items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), + ) + }, + updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { + setStore("context", "items", (items) => + items.map((item) => { + if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item + const value = { ...item, ...next } + return { ...value, key: contextItemKey(value) } + }), + ) + }, + replaceComments(items: FileContextItem[]) { + setStore("context", "items", (current) => [ + ...current.filter((item) => !isCommentItem(item)), + ...items.map((item) => ({ ...item, key: contextItemKey(item) })), + ]) + }, + }, + set: actions.set, + reset: actions.reset, + } +} + +export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ + name: "Prompt", + gate: false, + init: () => { + const params = useParams() + const cache = new Map() + + const disposeAll = () => { + for (const entry of cache.values()) { + entry.dispose() + } + cache.clear() + } + + onCleanup(disposeAll) + + const prune = () => { + while (cache.size > MAX_PROMPT_SESSIONS) { + const first = cache.keys().next().value + if (!first) return + const entry = cache.get(first) + entry?.dispose() + cache.delete(first) + } + } + + const load = (dir: string, id: string | undefined) => { + const key = `${dir}:${id ?? WORKSPACE_KEY}` + const existing = cache.get(key) + if (existing) { + cache.delete(key) + cache.set(key, existing) + return existing.value + } + + const entry = createRoot((dispose) => ({ + value: createPromptSession(dir, id), + dispose, + })) + + cache.set(key, entry) + prune() + return entry.value + } + + const session = createMemo(() => load(params.dir!, params.id)) + + return { + ready: () => session().ready(), + current: () => session().current(), + cursor: () => session().cursor(), + dirty: () => session().dirty(), + context: { + items: () => session().context.items(), + add: (item: ContextItem) => session().context.add(item), + remove: (key: string) => session().context.remove(key), + removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID), + updateComment: (path: string, commentID: string, next: Partial & { comment?: string }) => + session().context.updateComment(path, commentID, next), + replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), + }, + set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition), + reset: () => session().reset(), + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/sdk.tsx b/Agent-backend/packages/app/src/context/sdk.tsx new file mode 100644 index 000000000..bc97ea13a --- /dev/null +++ b/Agent-backend/packages/app/src/context/sdk.tsx @@ -0,0 +1,49 @@ +import type { Event } from "@opencode-ai/sdk/v2/client" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" +import { useGlobalSDK } from "./global-sdk" + +type SDKEventMap = { + [key in Event["type"]]: Extract +} + +export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ + name: "SDK", + init: (props: { directory: Accessor }) => { + const globalSDK = useGlobalSDK() + + const directory = createMemo(props.directory) + const client = createMemo(() => + globalSDK.createClient({ + directory: directory(), + throwOnError: true, + }), + ) + + const emitter = createGlobalEmitter() + + createEffect(() => { + const unsub = globalSDK.event.on(directory(), (event) => { + emitter.emit(event.type, event) + }) + onCleanup(unsub) + }) + + return { + get directory() { + return directory() + }, + get client() { + return client() + }, + event: emitter, + get url() { + return globalSDK.url + }, + createClient(opts: Parameters[0]) { + return globalSDK.createClient(opts) + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/server.tsx b/Agent-backend/packages/app/src/context/server.tsx new file mode 100644 index 000000000..4ff777e2e --- /dev/null +++ b/Agent-backend/packages/app/src/context/server.tsx @@ -0,0 +1,297 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { usePlatform } from "@/context/platform" +import { Persist, persisted } from "@/utils/persist" +import { checkServerHealth } from "@/utils/server-health" + +type StoredProject = { worktree: string; expanded: boolean } +type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http +const HEALTH_POLL_INTERVAL_MS = 10_000 + +export function normalizeServerUrl(input: string) { + const trimmed = input.trim() + if (!trimmed) return + const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}` + return withProtocol.replace(/\/+$/, "") +} + +export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) { + if (!conn) return "" + if (conn.displayName && !ignoreDisplayName) return conn.displayName + return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "") +} + +function projectsKey(key: ServerConnection.Key) { + if (!key) return "" + if (key === "sidecar") return "local" + if (isLocalHost(key)) return "local" + return key +} + +function isLocalHost(url: string) { + const host = url.replace(/^https?:\/\//, "").split(":")[0] + if (host === "localhost" || host === "127.0.0.1") return "local" +} + +export namespace ServerConnection { + type Base = { displayName?: string } + + export type HttpBase = { + url: string + username?: string + password?: string + } + + // Regular web connections + export type Http = { + type: "http" + http: HttpBase + } & Base + + export type Sidecar = { + type: "sidecar" + http: HttpBase + } & ( + | // Regular desktop server + { variant: "base" } + // WSL server (windows only) + | { + variant: "wsl" + distro: string + } + ) & + Base + + // Remote server desktop can SSH into + export type Ssh = { + type: "ssh" + host: string + // SSH client exposes an HTTP server for the app to use as a proxy + http: HttpBase + } & Base + + export type Any = + | Http + // All these are desktop-only + | (Sidecar | Ssh) + + export const key = (conn: Any): Key => { + switch (conn.type) { + case "http": + return Key.make(conn.http.url) + case "sidecar": { + if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`) + return Key.make("sidecar") + } + case "ssh": + return Key.make(`ssh:${conn.host}`) + } + } + + export type Key = string & { _brand: "Key" } + export const Key = { make: (v: string) => v as Key } +} + +export const { use: useServer, provider: ServerProvider } = createSimpleContext({ + name: "Server", + init: (props: { defaultServer: ServerConnection.Key; servers?: Array }) => { + const platform = usePlatform() + + const [store, setStore, _, ready] = persisted( + Persist.global("server", ["server.v3"]), + createStore({ + list: [] as StoredServer[], + projects: {} as Record, + lastProject: {} as Record, + }), + ) + + const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url) + + const allServers = createMemo((): Array => { + const servers = [ + ...(props.servers ?? []), + ...store.list.map((value) => + typeof value === "string" + ? { + type: "http" as const, + http: { url: value }, + } + : value, + ), + ] + + const deduped = new Map( + servers.map((value) => { + const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value } + return [ServerConnection.key(conn), conn] + }), + ) + + return [...deduped.values()] + }) + + const [state, setState] = createStore({ + active: props.defaultServer, + healthy: undefined as boolean | undefined, + }) + + const healthy = () => state.healthy + + function startHealthPolling(conn: ServerConnection.Any) { + let alive = true + let busy = false + + const run = () => { + if (busy) return + busy = true + void check(conn) + .then((next) => { + if (!alive) return + setState("healthy", next) + }) + .finally(() => { + busy = false + }) + } + + run() + const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS) + return () => { + alive = false + clearInterval(interval) + } + } + + function setActive(input: ServerConnection.Key) { + if (state.active !== input) setState("active", input) + } + + function add(input: ServerConnection.Http) { + const url_ = normalizeServerUrl(input.http.url) + if (!url_) return + const conn = { ...input, http: { ...input.http, url: url_ } } + return batch(() => { + const existing = store.list.findIndex((x) => url(x) === url_) + if (existing !== -1) { + setStore("list", existing, conn) + } else { + setStore("list", store.list.length, conn) + } + setState("active", ServerConnection.key(conn)) + return conn + }) + } + + function remove(key: ServerConnection.Key) { + const list = store.list.filter((x) => url(x) !== key) + batch(() => { + setStore("list", list) + if (state.active === key) { + const next = list[0] + setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer) + } + }) + } + + const isReady = createMemo(() => ready() && !!state.active) + + const fetcher = platform.fetch ?? globalThis.fetch + const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy) + + createEffect(() => { + const current_ = current() + if (!current_) return + + setState("healthy", undefined) + onCleanup(startHealthPolling(current_)) + }) + + const origin = createMemo(() => projectsKey(state.active)) + const projectsList = createMemo(() => store.projects[origin()] ?? []) + const current: Accessor = createMemo( + () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], + ) + const isLocal = createMemo(() => { + const c = current() + return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url)) + }) + + return { + ready: isReady, + healthy, + isLocal, + get key() { + return state.active + }, + get name() { + return serverName(current()) + }, + get list() { + return allServers() + }, + get current() { + return current() + }, + setActive, + add, + remove, + projects: { + list: projectsList, + open(directory: string) { + const key = origin() + if (!key) return + const current = store.projects[key] ?? [] + if (current.find((x) => x.worktree === directory)) return + setStore("projects", key, [{ worktree: directory, expanded: true }, ...current]) + }, + close(directory: string) { + const key = origin() + if (!key) return + const current = store.projects[key] ?? [] + setStore( + "projects", + key, + current.filter((x) => x.worktree !== directory), + ) + }, + expand(directory: string) { + const key = origin() + if (!key) return + const current = store.projects[key] ?? [] + const index = current.findIndex((x) => x.worktree === directory) + if (index !== -1) setStore("projects", key, index, "expanded", true) + }, + collapse(directory: string) { + const key = origin() + if (!key) return + const current = store.projects[key] ?? [] + const index = current.findIndex((x) => x.worktree === directory) + if (index !== -1) setStore("projects", key, index, "expanded", false) + }, + move(directory: string, toIndex: number) { + const key = origin() + if (!key) return + const current = store.projects[key] ?? [] + const fromIndex = current.findIndex((x) => x.worktree === directory) + if (fromIndex === -1 || fromIndex === toIndex) return + const result = [...current] + const [item] = result.splice(fromIndex, 1) + result.splice(toIndex, 0, item) + setStore("projects", key, result) + }, + last() { + const key = origin() + if (!key) return + return store.lastProject[key] + }, + touch(directory: string) { + const key = origin() + if (!key) return + setStore("lastProject", key, directory) + }, + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/settings.tsx b/Agent-backend/packages/app/src/context/settings.tsx new file mode 100644 index 000000000..b43469b5c --- /dev/null +++ b/Agent-backend/packages/app/src/context/settings.tsx @@ -0,0 +1,235 @@ +import { createStore, reconcile } from "solid-js/store" +import { createEffect, createMemo } from "solid-js" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { persisted } from "@/utils/persist" + +export interface NotificationSettings { + agent: boolean + permissions: boolean + errors: boolean +} + +export interface SoundSettings { + agentEnabled: boolean + agent: string + permissionsEnabled: boolean + permissions: string + errorsEnabled: boolean + errors: string +} + +export interface Settings { + general: { + autoSave: boolean + releaseNotes: boolean + showReasoningSummaries: boolean + shellToolPartsExpanded: boolean + editToolPartsExpanded: boolean + } + updates: { + startup: boolean + } + appearance: { + fontSize: number + font: string + } + keybinds: Record + permissions: { + autoApprove: boolean + } + notifications: NotificationSettings + sounds: SoundSettings +} + +const defaultSettings: Settings = { + general: { + autoSave: true, + releaseNotes: true, + showReasoningSummaries: false, + shellToolPartsExpanded: true, + editToolPartsExpanded: false, + }, + updates: { + startup: true, + }, + appearance: { + fontSize: 14, + font: "ibm-plex-mono", + }, + keybinds: {}, + permissions: { + autoApprove: false, + }, + notifications: { + agent: true, + permissions: true, + errors: false, + }, + sounds: { + agentEnabled: true, + agent: "staplebops-01", + permissionsEnabled: true, + permissions: "staplebops-02", + errorsEnabled: true, + errors: "nope-03", + }, +} + +const monoFallback = + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' + +const monoFonts: Record = { + "ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "cascadia-code": `"Cascadia Code Nerd Font", "Cascadia Code NF", "Cascadia Mono NF", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "fira-code": `"Fira Code Nerd Font", "FiraMono Nerd Font", "FiraMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, + "geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`, +} + +export function monoFontFamily(font: string | undefined) { + return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font] +} + +function withFallback(read: () => T | undefined, fallback: T) { + return createMemo(() => read() ?? fallback) +} + +export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ + name: "Settings", + init: () => { + const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) + + createEffect(() => { + if (typeof document === "undefined") return + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + }) + + return { + ready, + get current() { + return store + }, + general: { + autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave), + setAutoSave(value: boolean) { + setStore("general", "autoSave", value) + }, + releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes), + setReleaseNotes(value: boolean) { + setStore("general", "releaseNotes", value) + }, + showReasoningSummaries: withFallback( + () => store.general?.showReasoningSummaries, + defaultSettings.general.showReasoningSummaries, + ), + setShowReasoningSummaries(value: boolean) { + setStore("general", "showReasoningSummaries", value) + }, + shellToolPartsExpanded: withFallback( + () => store.general?.shellToolPartsExpanded, + defaultSettings.general.shellToolPartsExpanded, + ), + setShellToolPartsExpanded(value: boolean) { + setStore("general", "shellToolPartsExpanded", value) + }, + editToolPartsExpanded: withFallback( + () => store.general?.editToolPartsExpanded, + defaultSettings.general.editToolPartsExpanded, + ), + setEditToolPartsExpanded(value: boolean) { + setStore("general", "editToolPartsExpanded", value) + }, + }, + updates: { + startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), + setStartup(value: boolean) { + setStore("updates", "startup", value) + }, + }, + appearance: { + fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), + setFontSize(value: number) { + setStore("appearance", "fontSize", value) + }, + font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font), + setFont(value: string) { + setStore("appearance", "font", value) + }, + }, + keybinds: { + get: (action: string) => store.keybinds?.[action], + set(action: string, keybind: string) { + setStore("keybinds", action, keybind) + }, + reset(action: string) { + setStore("keybinds", (current) => { + if (!Object.prototype.hasOwnProperty.call(current, action)) return current + const next = { ...current } + delete next[action] + return next + }) + }, + resetAll() { + setStore("keybinds", reconcile({})) + }, + }, + permissions: { + autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove), + setAutoApprove(value: boolean) { + setStore("permissions", "autoApprove", value) + }, + }, + notifications: { + agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent), + setAgent(value: boolean) { + setStore("notifications", "agent", value) + }, + permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions), + setPermissions(value: boolean) { + setStore("notifications", "permissions", value) + }, + errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors), + setErrors(value: boolean) { + setStore("notifications", "errors", value) + }, + }, + sounds: { + agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled), + setAgentEnabled(value: boolean) { + setStore("sounds", "agentEnabled", value) + }, + agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent), + setAgent(value: string) { + setStore("sounds", "agent", value) + }, + permissionsEnabled: withFallback( + () => store.sounds?.permissionsEnabled, + defaultSettings.sounds.permissionsEnabled, + ), + setPermissionsEnabled(value: boolean) { + setStore("sounds", "permissionsEnabled", value) + }, + permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions), + setPermissions(value: string) { + setStore("sounds", "permissions", value) + }, + errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled), + setErrorsEnabled(value: boolean) { + setStore("sounds", "errorsEnabled", value) + }, + errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors), + setErrors(value: string) { + setStore("sounds", "errors", value) + }, + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/sync-optimistic.test.ts b/Agent-backend/packages/app/src/context/sync-optimistic.test.ts new file mode 100644 index 000000000..7deeddd6e --- /dev/null +++ b/Agent-backend/packages/app/src/context/sync-optimistic.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { applyOptimisticAdd, applyOptimisticRemove } from "./sync" + +const userMessage = (id: string, sessionID: string): Message => ({ + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "assistant", + model: { providerID: "openai", modelID: "gpt" }, +}) + +const textPart = (id: string, sessionID: string, messageID: string): Part => ({ + id, + sessionID, + messageID, + type: "text", + text: id, +}) + +describe("sync optimistic reducers", () => { + test("applyOptimisticAdd inserts message in sorted order and stores parts", () => { + const sessionID = "ses_1" + const draft = { + message: { [sessionID]: [userMessage("msg_2", sessionID)] }, + part: {} as Record, + } + + applyOptimisticAdd(draft, { + sessionID, + message: userMessage("msg_1", sessionID), + parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")], + }) + + expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"]) + expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) + }) + + test("applyOptimisticRemove removes message and part entries", () => { + const sessionID = "ses_1" + const draft = { + message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] }, + part: { + msg_1: [textPart("prt_1", sessionID, "msg_1")], + msg_2: [textPart("prt_2", sessionID, "msg_2")], + } as Record, + } + + applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" }) + + expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"]) + expect(draft.part.msg_1).toBeUndefined() + expect(draft.part.msg_2).toHaveLength(1) + }) +}) diff --git a/Agent-backend/packages/app/src/context/sync.tsx b/Agent-backend/packages/app/src/context/sync.tsx new file mode 100644 index 000000000..5623a2c7c --- /dev/null +++ b/Agent-backend/packages/app/src/context/sync.tsx @@ -0,0 +1,439 @@ +import { batch, createMemo } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { Binary } from "@opencode-ai/util/binary" +import { retry } from "@opencode-ai/util/retry" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useGlobalSync } from "./global-sync" +import { useSDK } from "./sdk" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" + +function sortParts(parts: Part[]) { + return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) +} + +function runInflight(map: Map>, key: string, task: () => Promise) { + const pending = map.get(key) + if (pending) return pending + const promise = task().finally(() => { + map.delete(key) + }) + map.set(key, promise) + return promise +} + +const keyFor = (directory: string, id: string) => `${directory}\n${id}` + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +type OptimisticStore = { + message: Record + part: Record +} + +type OptimisticAddInput = { + sessionID: string + message: Message + parts: Part[] +} + +type OptimisticRemoveInput = { + sessionID: string + messageID: string +} + +export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, input.message.id, (m) => m.id) + messages.splice(result.index, 0, input.message) + } else { + draft.message[input.sessionID] = [input.message] + } + draft.part[input.message.id] = sortParts(input.parts) +} + +export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, input.messageID, (m) => m.id) + if (result.found) messages.splice(result.index, 1) + } + delete draft.part[input.messageID] +} + +function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) { + setStore("message", input.sessionID, (messages: Message[] | undefined) => { + if (!messages) return [input.message] + const result = Binary.search(messages, input.message.id, (m) => m.id) + const next = [...messages] + next.splice(result.index, 0, input.message) + return next + }) + setStore("part", input.message.id, sortParts(input.parts)) +} + +function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) { + setStore("message", input.sessionID, (messages: Message[] | undefined) => { + if (!messages) return messages + const result = Binary.search(messages, input.messageID, (m) => m.id) + if (!result.found) return messages + const next = [...messages] + next.splice(result.index, 1) + return next + }) + setStore("part", (part: Record) => { + if (!(input.messageID in part)) return part + const next = { ...part } + delete next[input.messageID] + return next + }) +} + +export const { use: useSync, provider: SyncProvider } = createSimpleContext({ + name: "Sync", + init: () => { + const globalSync = useGlobalSync() + const sdk = useSDK() + + type Child = ReturnType<(typeof globalSync)["child"]> + type Setter = Child[1] + + const current = createMemo(() => globalSync.child(sdk.directory)) + const target = (directory?: string) => { + if (!directory || directory === sdk.directory) return current() + return globalSync.child(directory) + } + const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") + const messagePageSize = 200 + const inflight = new Map>() + const inflightDiff = new Map>() + const inflightTodo = new Map>() + const maxDirs = 30 + const seen = new Map>() + const [meta, setMeta] = createStore({ + limit: {} as Record, + complete: {} as Record, + loading: {} as Record, + }) + + const getSession = (sessionID: string) => { + const store = current()[0] + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (match.found) return store.session[match.index] + return undefined + } + + const seenFor = (directory: string) => { + const existing = seen.get(directory) + if (existing) { + seen.delete(directory) + seen.set(directory, existing) + return existing + } + const created = new Set() + seen.set(directory, created) + while (seen.size > maxDirs) { + const first = seen.keys().next().value + if (!first) break + const stale = [...(seen.get(first) ?? [])] + seen.delete(first) + const [, setStore] = globalSync.child(first, { bootstrap: false }) + evict(first, setStore, stale) + } + return created + } + + const clearMeta = (directory: string, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + setMeta( + produce((draft) => { + for (const sessionID of sessionIDs) { + const key = keyFor(directory, sessionID) + delete draft.limit[key] + delete draft.complete[key] + delete draft.loading[key] + } + }), + ) + } + + const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + for (const sessionID of sessionIDs) { + globalSync.todo.set(sessionID, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, sessionIDs) + }), + ) + clearMeta(directory, sessionIDs) + } + + const touch = (directory: string, setStore: Setter, sessionID: string) => { + const stale = pickSessionCacheEvictions({ + seen: seenFor(directory), + keep: sessionID, + limit: SESSION_CACHE_LIMIT, + }) + evict(directory, setStore, stale) + } + + const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => { + const messages = await retry(() => + input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }), + ) + const items = (messages.data ?? []).filter((x) => !!x?.info?.id) + const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id)) + const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) + return { + session, + part, + complete: session.length < input.limit, + } + } + + const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false + + const loadMessages = async (input: { + directory: string + client: typeof sdk.client + setStore: Setter + sessionID: string + limit: number + }) => { + const key = keyFor(input.directory, input.sessionID) + if (meta.loading[key]) return + + setMeta("loading", key, true) + await fetchMessages(input) + .then((next) => { + if (!tracked(input.directory, input.sessionID)) return + batch(() => { + input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) + for (const p of next.part) { + input.setStore("part", p.id, p.part) + } + setMeta("limit", key, input.limit) + setMeta("complete", key, next.complete) + }) + }) + .finally(() => { + if (!tracked(input.directory, input.sessionID)) return + setMeta("loading", key, false) + }) + } + + return { + get data() { + return current()[0] + }, + get set(): Setter { + return current()[1] + }, + get status() { + return current()[0].status + }, + get ready() { + return current()[0].status !== "loading" + }, + get project() { + const store = current()[0] + const match = Binary.search(globalSync.data.project, store.project, (p) => p.id) + if (match.found) return globalSync.data.project[match.index] + return undefined + }, + session: { + get: getSession, + optimistic: { + add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { + const [, setStore] = target(input.directory) + setOptimisticAdd(setStore as (...args: unknown[]) => void, input) + }, + remove(input: { directory?: string; sessionID: string; messageID: string }) { + const [, setStore] = target(input.directory) + setOptimisticRemove(setStore as (...args: unknown[]) => void, input) + }, + }, + addOptimisticMessage(input: { + sessionID: string + messageID: string + parts: Part[] + agent: string + model: { providerID: string; modelID: string } + variant?: string + }) { + const message: Message = { + id: input.messageID, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.agent, + model: input.model, + variant: input.variant, + } + const [, setStore] = target() + setOptimisticAdd(setStore as (...args: unknown[]) => void, { + sessionID: input.sessionID, + message, + parts: input.parts, + }) + }, + async sync(sessionID: string) { + const directory = sdk.directory + const client = sdk.client + const [store, setStore] = globalSync.child(directory) + const key = keyFor(directory, sessionID) + const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + + touch(directory, setStore, sessionID) + + if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return + + const limit = meta.limit[key] ?? messagePageSize + + const sessionReq = hasSession + ? Promise.resolve() + : retry(() => client.session.get({ sessionID })).then((session) => { + if (!tracked(directory, sessionID)) return + const data = session.data + if (!data) return + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) { + draft[match.index] = data + return + } + draft.splice(match.index, 0, data) + }), + ) + }) + + const messagesReq = loadMessages({ + directory, + client, + setStore, + sessionID, + limit, + }) + + return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) + }, + async diff(sessionID: string) { + const directory = sdk.directory + const client = sdk.client + const [store, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) + if (store.session_diff[sessionID] !== undefined) return + + const key = keyFor(directory, sessionID) + return runInflight(inflightDiff, key, () => + retry(() => client.session.diff({ sessionID })).then((diff) => { + if (!tracked(directory, sessionID)) return + setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) + }), + ) + }, + async todo(sessionID: string) { + const directory = sdk.directory + const client = sdk.client + const [store, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) + const existing = store.todo[sessionID] + const cached = globalSync.data.session_todo[sessionID] + if (existing !== undefined) { + if (cached === undefined) { + globalSync.todo.set(sessionID, existing) + } + return + } + + if (cached !== undefined) { + setStore("todo", sessionID, reconcile(cached, { key: "id" })) + } + + const key = keyFor(directory, sessionID) + return runInflight(inflightTodo, key, () => + retry(() => client.session.todo({ sessionID })).then((todo) => { + if (!tracked(directory, sessionID)) return + const list = todo.data ?? [] + setStore("todo", sessionID, reconcile(list, { key: "id" })) + globalSync.todo.set(sessionID, list) + }), + ) + }, + history: { + more(sessionID: string) { + const store = current()[0] + const key = keyFor(sdk.directory, sessionID) + if (store.message[sessionID] === undefined) return false + if (meta.limit[key] === undefined) return false + if (meta.complete[key]) return false + return true + }, + loading(sessionID: string) { + const key = keyFor(sdk.directory, sessionID) + return meta.loading[key] ?? false + }, + async loadMore(sessionID: string, count?: number) { + const directory = sdk.directory + const client = sdk.client + const [, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) + const key = keyFor(directory, sessionID) + const step = count ?? messagePageSize + if (meta.loading[key]) return + if (meta.complete[key]) return + + const currentLimit = meta.limit[key] ?? messagePageSize + await loadMessages({ + directory, + client, + setStore, + sessionID, + limit: currentLimit + step, + }) + }, + }, + evict(sessionID: string, directory = sdk.directory) { + const [, setStore] = globalSync.child(directory) + seenFor(directory).delete(sessionID) + evict(directory, setStore, [sessionID]) + }, + fetch: async (count = 10) => { + const directory = sdk.directory + const client = sdk.client + const [store, setStore] = globalSync.child(directory) + setStore("limit", (x) => x + count) + await client.session.list().then((x) => { + const sessions = (x.data ?? []) + .filter((s) => !!s?.id) + .sort((a, b) => cmp(a.id, b.id)) + .slice(0, store.limit) + setStore("session", reconcile(sessions, { key: "id" })) + }) + }, + more: createMemo(() => current()[0].session.length >= current()[0].limit), + archive: async (sessionID: string) => { + const directory = sdk.directory + const client = sdk.client + const [, setStore] = globalSync.child(directory) + await client.session.update({ sessionID, time: { archived: Date.now() } }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + }, + }, + absolute, + get directory() { + return current()[0].path.directory + }, + } + }, +}) diff --git a/Agent-backend/packages/app/src/context/terminal.test.ts b/Agent-backend/packages/app/src/context/terminal.test.ts new file mode 100644 index 000000000..6e07e0312 --- /dev/null +++ b/Agent-backend/packages/app/src/context/terminal.test.ts @@ -0,0 +1,82 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" + +let getWorkspaceTerminalCacheKey: (dir: string) => string +let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] +let migrateTerminalState: (value: unknown) => unknown + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, + useParams: () => ({}), + })) + mock.module("@opencode-ai/ui/context", () => ({ + createSimpleContext: () => ({ + use: () => undefined, + provider: () => undefined, + }), + })) + const mod = await import("./terminal") + getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey + getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys + migrateTerminalState = mod.migrateTerminalState +}) + +describe("getWorkspaceTerminalCacheKey", () => { + test("uses workspace-only directory cache key", () => { + expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") + }) +}) + +describe("getLegacyTerminalStorageKeys", () => { + test("keeps workspace storage path when no legacy session id", () => { + expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"]) + }) + + test("includes legacy session path before workspace path", () => { + expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([ + "/repo/terminal/session-123.v1", + "/repo/terminal.v1", + ]) + }) +}) + +describe("migrateTerminalState", () => { + test("drops invalid terminals and restores a valid active terminal", () => { + expect( + migrateTerminalState({ + active: "missing", + all: [ + null, + { id: "one", title: "Terminal 2" }, + { id: "one", title: "duplicate", titleNumber: 9 }, + { id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 }, + { title: "no-id" }, + ], + }), + ).toEqual({ + active: "one", + all: [ + { id: "one", title: "Terminal 2", titleNumber: 2 }, + { id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 }, + ], + }) + }) + + test("keeps a valid active id", () => { + expect( + migrateTerminalState({ + active: "two", + all: [ + { id: "one", title: "Terminal 1" }, + { id: "two", title: "shell", titleNumber: 7 }, + ], + }), + ).toEqual({ + active: "two", + all: [ + { id: "one", title: "Terminal 1", titleNumber: 1 }, + { id: "two", title: "shell", titleNumber: 7 }, + ], + }) + }) +}) diff --git a/Agent-backend/packages/app/src/context/terminal.tsx b/Agent-backend/packages/app/src/context/terminal.tsx new file mode 100644 index 000000000..a2807375f --- /dev/null +++ b/Agent-backend/packages/app/src/context/terminal.tsx @@ -0,0 +1,416 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js" +import { useParams } from "@solidjs/router" +import { useSDK } from "./sdk" +import type { Platform } from "./platform" +import { Persist, persisted, removePersisted } from "@/utils/persist" + +export type LocalPTY = { + id: string + title: string + titleNumber: number + rows?: number + cols?: number + buffer?: string + scrollY?: number + cursor?: number +} + +const WORKSPACE_KEY = "__workspace__" +const MAX_TERMINAL_SESSIONS = 20 + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function text(value: unknown) { + return typeof value === "string" ? value : undefined +} + +function num(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function numberFromTitle(title: string) { + const match = title.match(/^Terminal (\d+)$/) + if (!match) return + const value = Number(match[1]) + if (!Number.isFinite(value) || value <= 0) return + return value +} + +function pty(value: unknown): LocalPTY | undefined { + if (!record(value)) return + + const id = text(value.id) + if (!id) return + + const title = text(value.title) ?? "" + const number = num(value.titleNumber) + const rows = num(value.rows) + const cols = num(value.cols) + const buffer = text(value.buffer) + const scrollY = num(value.scrollY) + const cursor = num(value.cursor) + + return { + id, + title, + titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0), + ...(rows !== undefined ? { rows } : {}), + ...(cols !== undefined ? { cols } : {}), + ...(buffer !== undefined ? { buffer } : {}), + ...(scrollY !== undefined ? { scrollY } : {}), + ...(cursor !== undefined ? { cursor } : {}), + } +} + +export function migrateTerminalState(value: unknown) { + if (!record(value)) return value + + const seen = new Set() + const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => { + const next = pty(item) + if (!next || seen.has(next.id)) return [] + seen.add(next.id) + return [next] + }) + + const active = text(value.active) + + return { + active: active && seen.has(active) ? active : all[0]?.id, + all, + } +} + +export function getWorkspaceTerminalCacheKey(dir: string) { + return `${dir}:${WORKSPACE_KEY}` +} + +export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { + if (!legacySessionID) return [`${dir}/terminal.v1`] + return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] +} + +type TerminalSession = ReturnType + +type TerminalCacheEntry = { + value: TerminalSession + dispose: VoidFunction +} + +const caches = new Set>() + +const trimTerminal = (pty: LocalPTY) => { + if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty + return { + ...pty, + buffer: undefined, + cursor: undefined, + scrollY: undefined, + } +} + +export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { + const key = getWorkspaceTerminalCacheKey(dir) + for (const cache of caches) { + const entry = cache.get(key) + entry?.value.clear() + } + + removePersisted(Persist.workspace(dir, "terminal"), platform) + + const legacy = new Set(getLegacyTerminalStorageKeys(dir)) + for (const id of sessionIDs ?? []) { + for (const key of getLegacyTerminalStorageKeys(dir, id)) { + legacy.add(key) + } + } + for (const key of legacy) { + removePersisted({ key }, platform) + } +} + +function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) { + const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID) + + const [store, setStore, _, ready] = persisted( + { + ...Persist.workspace(dir, "terminal", legacy), + migrate: migrateTerminalState, + }, + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + ) + + const pickNextTerminalNumber = () => { + const existingTitleNumbers = new Set( + store.all.flatMap((pty) => { + const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined + if (direct !== undefined) return [direct] + const parsed = numberFromTitle(pty.title) + if (parsed === undefined) return [] + return [parsed] + }), + ) + + return ( + Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find( + (number) => !existingTitleNumbers.has(number), + ) ?? 1 + ) + } + + const removeExited = (id: string) => { + const all = store.all + const index = all.findIndex((x) => x.id === id) + if (index === -1) return + const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active + batch(() => { + setStore("active", active) + setStore( + "all", + produce((draft) => { + draft.splice(index, 1) + }), + ) + }) + } + + const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { + removeExited(event.properties.id) + }) + onCleanup(unsub) + + return { + ready, + all: createMemo(() => store.all), + active: createMemo(() => store.active), + clear() { + batch(() => { + setStore("active", undefined) + setStore("all", []) + }) + }, + new() { + const nextNumber = pickNextTerminalNumber() + + sdk.client.pty + .create({ title: `Terminal ${nextNumber}` }) + .then((pty: { data?: { id?: string; title?: string } }) => { + const id = pty.data?.id + if (!id) return + const newTerminal = { + id, + title: pty.data?.title ?? "Terminal", + titleNumber: nextNumber, + } + setStore("all", store.all.length, newTerminal) + setStore("active", id) + }) + .catch((error: unknown) => { + console.error("Failed to create terminal", error) + }) + }, + update(pty: Partial & { id: string }) { + const index = store.all.findIndex((x) => x.id === pty.id) + const previous = index >= 0 ? store.all[index] : undefined + if (index >= 0) { + setStore("all", index, (item) => ({ ...item, ...pty })) + } + sdk.client.pty + .update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + .catch((error: unknown) => { + if (previous) { + const currentIndex = store.all.findIndex((item) => item.id === pty.id) + if (currentIndex >= 0) setStore("all", currentIndex, previous) + } + console.error("Failed to update terminal", error) + }) + }, + trim(id: string) { + const index = store.all.findIndex((x) => x.id === id) + if (index === -1) return + setStore("all", index, (pty) => trimTerminal(pty)) + }, + trimAll() { + setStore("all", (all) => { + const next = all.map(trimTerminal) + if (next.every((pty, index) => pty === all[index])) return all + return next + }) + }, + async clone(id: string) { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const clone = await sdk.client.pty + .create({ + title: pty.title, + }) + .catch((error: unknown) => { + console.error("Failed to clone terminal", error) + return undefined + }) + if (!clone?.data) return + + const active = store.active === pty.id + + batch(() => { + setStore("all", index, { + id: clone.data.id, + title: clone.data.title ?? pty.title, + titleNumber: pty.titleNumber, + // New PTY process, so start clean. + buffer: undefined, + cursor: undefined, + scrollY: undefined, + rows: undefined, + cols: undefined, + }) + if (active) { + setStore("active", clone.data.id) + } + }) + }, + open(id: string) { + setStore("active", id) + }, + next() { + const index = store.all.findIndex((x) => x.id === store.active) + if (index === -1) return + const nextIndex = (index + 1) % store.all.length + setStore("active", store.all[nextIndex]?.id) + }, + previous() { + const index = store.all.findIndex((x) => x.id === store.active) + if (index === -1) return + const prevIndex = index === 0 ? store.all.length - 1 : index - 1 + setStore("active", store.all[prevIndex]?.id) + }, + async close(id: string) { + const index = store.all.findIndex((f) => f.id === id) + if (index !== -1) { + batch(() => { + if (store.active === id) { + const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id + setStore("active", next) + } + setStore( + "all", + produce((all) => { + all.splice(index, 1) + }), + ) + }) + } + + await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { + console.error("Failed to close terminal", error) + }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + gate: false, + init: () => { + const sdk = useSDK() + const params = useParams() + const cache = new Map() + + caches.add(cache) + onCleanup(() => caches.delete(cache)) + + const disposeAll = () => { + for (const entry of cache.values()) { + entry.dispose() + } + cache.clear() + } + + onCleanup(disposeAll) + + const prune = () => { + while (cache.size > MAX_TERMINAL_SESSIONS) { + const first = cache.keys().next().value + if (!first) return + const entry = cache.get(first) + entry?.dispose() + cache.delete(first) + } + } + + const loadWorkspace = (dir: string, legacySessionID?: string) => { + // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. + const key = getWorkspaceTerminalCacheKey(dir) + const existing = cache.get(key) + if (existing) { + cache.delete(key) + cache.set(key, existing) + return existing.value + } + + const entry = createRoot((dispose) => ({ + value: createWorkspaceTerminalSession(sdk, dir, legacySessionID), + dispose, + })) + + cache.set(key, entry) + prune() + return entry.value + } + + const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) + + createEffect( + on( + () => ({ dir: params.dir, id: params.id }), + (next, prev) => { + if (!prev?.dir) return + if (next.dir === prev.dir && next.id === prev.id) return + if (next.dir === prev.dir && next.id) return + loadWorkspace(prev.dir, prev.id).trimAll() + }, + { defer: true }, + ), + ) + + return { + ready: () => workspace().ready(), + all: () => workspace().all(), + active: () => workspace().active(), + new: () => workspace().new(), + update: (pty: Partial & { id: string }) => workspace().update(pty), + trim: (id: string) => workspace().trim(id), + trimAll: () => workspace().trimAll(), + clone: (id: string) => workspace().clone(id), + open: (id: string) => workspace().open(id), + close: (id: string) => workspace().close(id), + move: (id: string, to: number) => workspace().move(id, to), + next: () => workspace().next(), + previous: () => workspace().previous(), + } + }, +}) diff --git a/Agent-backend/packages/app/src/custom-elements.d.ts b/Agent-backend/packages/app/src/custom-elements.d.ts new file mode 120000 index 000000000..e4ea0d6ce --- /dev/null +++ b/Agent-backend/packages/app/src/custom-elements.d.ts @@ -0,0 +1 @@ +../../ui/src/custom-elements.d.ts \ No newline at end of file diff --git a/Agent-backend/packages/app/src/entry.tsx b/Agent-backend/packages/app/src/entry.tsx new file mode 100644 index 000000000..e9c0a4397 --- /dev/null +++ b/Agent-backend/packages/app/src/entry.tsx @@ -0,0 +1,134 @@ +// @refresh reload + +import { iife } from "@opencode-ai/util/iife" +import { render } from "solid-js/web" +import { AppBaseProviders, AppInterface } from "@/app" +import { type Platform, PlatformProvider } from "@/context/platform" +import { dict as en } from "@/i18n/en" +import { dict as zh } from "@/i18n/zh" +import { handleNotificationClick } from "@/utils/notification-click" +import pkg from "../package.json" +import { ServerConnection } from "./context/server" + +const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" + +const getLocale = () => { + if (typeof navigator !== "object") return "en" as const + const languages = navigator.languages?.length ? navigator.languages : [navigator.language] + for (const language of languages) { + if (!language) continue + if (language.toLowerCase().startsWith("zh")) return "zh" as const + } + return "en" as const +} + +const getRootNotFoundError = () => { + const key = "error.dev.rootNotFound" as const + const locale = getLocale() + return locale === "zh" ? (zh[key] ?? en[key]) : en[key] +} + +const getStorage = (key: string) => { + if (typeof localStorage === "undefined") return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +const setStorage = (key: string, value: string | null) => { + if (typeof localStorage === "undefined") return + try { + if (value !== null) { + localStorage.setItem(key, value) + return + } + localStorage.removeItem(key) + } catch { + return + } +} + +const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) +const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) + +const notify: Platform["notify"] = async (title, description, href) => { + if (!("Notification" in window)) return + + const permission = + Notification.permission === "default" + ? await Notification.requestPermission().catch(() => "denied") + : Notification.permission + + if (permission !== "granted") return + + const inView = document.visibilityState === "visible" && document.hasFocus() + if (inView) return + + const notification = new Notification(title, { + body: description ?? "", + icon: "https://opencode.ai/favicon-96x96-v3.png", + }) + + notification.onclick = () => { + handleNotificationClick(href) + notification.close() + } +} + +const openLink: Platform["openLink"] = (url) => { + window.open(url, "_blank") +} + +const back: Platform["back"] = () => { + window.history.back() +} + +const forward: Platform["forward"] = () => { + window.history.forward() +} + +const restart: Platform["restart"] = async () => { + window.location.reload() +} + +const root = document.getElementById("root") +if (!(root instanceof HTMLElement) && import.meta.env.DEV) { + throw new Error(getRootNotFoundError()) +} + +const platform: Platform = { + platform: "web", + version: pkg.version, + openLink, + back, + forward, + restart, + notify, + getDefaultServerUrl: async () => readDefaultServerUrl(), + setDefaultServerUrl: writeDefaultServerUrl, +} + +const defaultUrl = iife(() => { + const lsDefault = readDefaultServerUrl() + if (lsDefault) return lsDefault + if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" + if (import.meta.env.DEV) + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + return location.origin +}) + +if (root instanceof HTMLElement) { + const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } } + render( + () => ( + + + + + + ), + root, + ) +} diff --git a/Agent-backend/packages/app/src/env.d.ts b/Agent-backend/packages/app/src/env.d.ts new file mode 100644 index 000000000..89721f34f --- /dev/null +++ b/Agent-backend/packages/app/src/env.d.ts @@ -0,0 +1,18 @@ +import "solid-js" + +interface ImportMetaEnv { + readonly VITE_OPENCODE_SERVER_HOST: string + readonly VITE_OPENCODE_SERVER_PORT: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +declare module "solid-js" { + namespace JSX { + interface Directives { + sortable: true + } + } +} diff --git a/Agent-backend/packages/app/src/hooks/CLAUDE.md b/Agent-backend/packages/app/src/hooks/CLAUDE.md new file mode 100644 index 000000000..88f5603b5 --- /dev/null +++ b/Agent-backend/packages/app/src/hooks/CLAUDE.md @@ -0,0 +1,25 @@ +# hooks + +> **Path:** `Agent-backend/packages/app/src/hooks/` + +## Purpose + +Custom SolidJS hooks for the OpenCode web app. Currently contains a single hook. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | --------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | The provider filtering pattern is directly relevant | +| Orbit CLI | `reference` | Same | + +## Files + +| File | Purpose | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `use-providers.ts` | Provider list hook — `useProviders()` returns computed lists: `all` (every provider), `connected` (authenticated), `paid` (connected with non-zero cost), `popular` (curated list of 8: opencode, anthropic, github-copilot, openai, google, openrouter, vercel, opencode-go), and `default`. Reads from `GlobalSync` context. | + +## Notes + +- **Only one hook** — most reactive logic lives in `@/context/` providers rather than standalone hooks. This directory may grow as the app adds more cross-cutting concerns. +- **`popularProviders` array** is exported and used by the model picker dialog for provider ordering. diff --git a/Agent-backend/packages/app/src/hooks/use-providers.ts b/Agent-backend/packages/app/src/hooks/use-providers.ts new file mode 100644 index 000000000..9ef5272ef --- /dev/null +++ b/Agent-backend/packages/app/src/hooks/use-providers.ts @@ -0,0 +1,42 @@ +import { useGlobalSync } from "@/context/global-sync" +import { decode64 } from "@/utils/base64" +import { useParams } from "@solidjs/router" +import { createMemo } from "solid-js" + +export const popularProviders = [ + "opencode", + "opencode-go", + "anthropic", + "github-copilot", + "openai", + "google", + "openrouter", + "vercel", +] +const popularProviderSet = new Set(popularProviders) + +export function useProviders() { + const globalSync = useGlobalSync() + const params = useParams() + const currentDirectory = createMemo(() => decode64(params.dir) ?? "") + const providers = createMemo(() => { + if (currentDirectory()) { + const [projectStore] = globalSync.child(currentDirectory()) + return projectStore.provider + } + return globalSync.data.provider + }) + const connectedIDs = createMemo(() => new Set(providers().connected)) + const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id))) + const paid = createMemo(() => + connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)), + ) + const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id))) + return { + all: createMemo(() => providers().all), + default: createMemo(() => providers().default), + popular, + connected, + paid, + } +} diff --git a/Agent-backend/packages/app/src/index.css b/Agent-backend/packages/app/src/index.css new file mode 100644 index 000000000..9e231e2d2 --- /dev/null +++ b/Agent-backend/packages/app/src/index.css @@ -0,0 +1,29 @@ +@import "@opencode-ai/ui/styles/tailwind"; + +@layer components { + [data-component="getting-started"] { + container-type: inline-size; + container-name: getting-started; + } + + [data-component="getting-started-actions"] { + display: flex; + flex-direction: column; + gap: 0.75rem; /* gap-3 */ + } + + [data-component="getting-started-actions"] > [data-component="button"] { + width: 100%; + } + + @container getting-started (min-width: 17rem) { + [data-component="getting-started-actions"] { + flex-direction: row; + align-items: center; + } + + [data-component="getting-started-actions"] > [data-component="button"] { + width: auto; + } + } +} diff --git a/Agent-backend/packages/app/src/index.ts b/Agent-backend/packages/app/src/index.ts new file mode 100644 index 000000000..6c870dfa4 --- /dev/null +++ b/Agent-backend/packages/app/src/index.ts @@ -0,0 +1,5 @@ +export { AppBaseProviders, AppInterface } from "./app" +export { useCommand } from "./context/command" +export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" +export { ServerConnection } from "./context/server" +export { handleNotificationClick } from "./utils/notification-click" diff --git a/Agent-backend/packages/app/src/pages/CLAUDE.md b/Agent-backend/packages/app/src/pages/CLAUDE.md new file mode 100644 index 000000000..4aa78e37e --- /dev/null +++ b/Agent-backend/packages/app/src/pages/CLAUDE.md @@ -0,0 +1,140 @@ +# pages + +> **Path:** `Agent-backend/packages/app/src/pages/` + +## Purpose + +Route-level page components for the OpenCode web app. Three routes: Home (`/`), Session (`/:slug/session/:id`), and a shared Layout wrapper. Two subdirectories (`layout/`, `session/`) extract sidebar, session panel, and composer logic into focused modules. The `session/` subdirectory is the largest and most complex — it contains the message timeline, terminal panel, file tabs, composer (permission/question/todo docks), review panel, and extensive helper logic. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | The session page architecture (message timeline, composer, review panel, terminal panel, file tabs) is the primary reference for Orbit's session view. The sidebar system (project list, workspace grouping, session items, drag-and-drop reordering) is directly relevant. | +| Orbit CLI | `reference` | Same | + +## Routes + +| Route | File | Purpose | +| -------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/` | `home.tsx` | Project list — shows 5 most recent projects, "Open Directory" and "Connect Server" buttons, server health indicator | +| `/:slug/session/:id` | `session.tsx` | **Main session view** — message timeline, prompt input, file tabs, terminal panel, review panel, session header. Responsive layout with media queries. | +| Wrapper | `layout.tsx` | **Shared layout** — sidebar (project list, session list, drag-and-drop), titlebar, notification/permission listeners, theme management, session tab persistence. 400+ lines. | +| Wrapper | `directory-layout.tsx` | Per-directory provider wrapper — wraps children with SDKProvider, SyncProvider, LocalProvider, DataProvider | +| Error | `error.tsx` | Error boundary page — formats `InitError` chains (MCP, provider, config errors), shows stack traces, offers "Restart" and "Check for Updates" buttons | + +## File Inventory + +### Root Files (5) + +| File | Purpose | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `home.tsx` | Home page — recent projects list with timestamps (Luxon), open/choose directory, server connection indicator | +| `session.tsx` | Session page — composes SessionHeader, MessageTimeline, PromptInput, TerminalPanel, SessionSidePanel, SessionReviewTab. Manages session history windowing (lazy load older messages), resize handles, mobile tab switching. | +| `layout.tsx` | Main layout — sidebar with project drag-and-drop (`@thisbeyond/solid-dnd`), session tab management, sound playback on agent events, notification click routing, deep link handling, session handoff, worktree state sync | +| `directory-layout.tsx` | Directory scope — wraps children with per-directory providers (SDK, Sync, Local, Data) | +| `error.tsx` | Error page — handles `InitError` types: MCPFailed, ProviderAuthError, APIError, ProviderModelNotFoundError, ConfigJsonError, ConfigDirectoryTypoError, ConfigFrontmatterError, ConfigInvalidError, UnknownError. Error chain formatting with cause traversal. | + +### Subdirectory: `layout/` (11 files) + +Sidebar components and helpers for the main layout. + +| File | Purpose | Tests | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `sidebar-project.tsx` | Project sidebar section — sortable project cards with drag-and-drop, hover cards showing session lists, context menus (archive, rename, workspaces), workspace grouping | | +| `sidebar-workspace.tsx` | Workspace sidebar — workspace-level grouping of sessions with expand/collapse | `sidebar-workspace.test.ts` | +| `sidebar-shell.tsx` | Shell sidebar section — terminal-initiated sessions grouped separately | `sidebar-shell.test.ts` | +| `sidebar-items.tsx` | Session list item components — `SessionItem` (title, time, status indicator), `ProjectIcon` (avatar with color system) | | +| `sidebar-project-helpers.ts` | Project sidebar pure logic — `projectSelected()`, `projectTileActive()` selection state | `sidebar-project-helpers.test.ts` | +| `sidebar-workspace-helpers.ts` | Workspace sidebar helpers — workspace-level logic | | +| `sidebar-shell-helpers.ts` | Shell sidebar helpers — shell session grouping logic | | +| `helpers.ts` | Shared sidebar helpers — `childMapByParent()` (builds parent→children session map), `displayName()` (session display name), `sortedRootSessions()` | `helpers.test.ts` | +| `deep-links.ts` | Deep link handler — routes `opencode://` protocol URLs to the correct session/project | | +| `inline-editor.tsx` | Inline text editing component — used for renaming sessions/projects in the sidebar | | + +### Subdirectory: `session/` (24 files) + +Session page extracted logic — the largest subdirectory. + +| File | Purpose | Tests | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `message-timeline.tsx` | **Core chat view** — virtual message list with ScrollView, session turns, message actions (fork, copy, revert, comment), file diff display, context usage indicator. Binary search for message lookups. | | +| `terminal-panel.tsx` | Terminal panel — Ghostty instances in tabs, split/close actions, resize handle, buffer persistence | `terminal-panel.test.ts` | +| `file-tabs.tsx` | File tab bar — sortable tabs showing open files, close actions, scroll navigation | | +| `file-tab-scroll.ts` | File tab scroll helpers — horizontal scroll with mouse wheel, overflow detection | `file-tab-scroll.test.ts` | +| `review-tab.tsx` | Code review panel — file diffs with unified/split view toggle, expand/collapse, comment annotations | | +| `session-side-panel.tsx` | Side panel — file tree + review tab in a resizable panel alongside the main chat | | +| `session-mobile-tabs.tsx` | Mobile tab switcher — chat/files/review tabs for narrow viewports | | +| `helpers.ts` | Session helpers — `createOpenReviewFile()`, `createSizing()` for responsive layout calculations | `helpers.test.ts` | +| `handoff.ts` | Session handoff state — passes session context between route navigations | | +| `terminal-label.ts` | Terminal tab label generation — "Terminal 1", "Terminal 2", etc. | | +| `message-id-from-hash.ts` | URL hash → message ID extraction — `#message-` deep linking | | +| `message-gesture.ts` | Touch/wheel gesture detection for message boundary scrolling — `normalizeWheelDelta()`, `shouldMarkBoundaryGesture()` | `message-gesture.test.ts` | +| `session-command-helpers.ts` | Session-level command palette actions | | +| `session-model-helpers.ts` | Model sync helpers — `syncSessionModel()`, `resetSessionModel()` keep session model in sync with global selection | `session-model-helpers.test.ts` | +| `session-prompt-helpers.ts` | Prompt state helpers for the session page | | +| `use-session-commands.tsx` | Session-specific command registrations (archive, rename, copy, share, fork, etc.) | `use-session-commands.test.ts` | +| `use-session-hash-scroll.ts` | Hash-based scroll — scrolls to a specific message when URL contains `#message-` | `use-session-hash-scroll.test.ts` | + +### Subdirectory: `session/composer/` (7 files) + +Composer region — the bottom dock area handling permissions, questions, and todos. + +| File | Purpose | Tests | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `session-composer-state.ts` | Composer state machine — tracks pending permissions, questions, todos. `createSessionComposerBlocked()` determines if agent is blocked waiting for user input. Auto-close timer for resolved items. | `session-composer-state.test.ts` | +| `session-composer-region.tsx` | Composer container — renders the appropriate dock (permission, question, or todo) based on state | | +| `session-permission-dock.tsx` | Permission request dock — shows tool permission with "Allow Once", "Always Allow", "Reject" buttons | | +| `session-question-dock.tsx` | Question dock — shows agent question with text input for user response | | +| `session-todo-dock.tsx` | Todo dock — shows agent-generated todo items with checkboxes | | +| `session-request-tree.ts` | Request tree helpers — `sessionPermissionRequest()`, `sessionQuestionRequest()` extract the next pending request for a session | | +| `index.ts` | Barrel export — `SessionComposerRegion`, `createSessionComposerState`, `createSessionComposerBlocked` | | + +## Test Coverage + +| Test File | Covers | +| ------------------------------------------------- | ----------------------------------------- | +| `layout/helpers.test.ts` | Session sorting, child map, display names | +| `layout/sidebar-project-helpers.test.ts` | Project selection state | +| `layout/sidebar-shell.test.ts` | Shell session grouping | +| `layout/sidebar-workspace.test.ts` | Workspace session grouping | +| `session/helpers.test.ts` | Sizing calculations, review file opening | +| `session/file-tab-scroll.test.ts` | Horizontal scroll and overflow | +| `session/message-gesture.test.ts` | Wheel delta normalization | +| `session/session-model-helpers.test.ts` | Model sync/reset | +| `session/session-prompt-dock.test.ts` | Prompt dock state | +| `session/terminal-panel.test.ts` | Terminal panel lifecycle | +| `session/use-session-commands.test.ts` | Session command registrations | +| `session/use-session-hash-scroll.test.ts` | Hash scroll behavior | +| `session/composer/session-composer-state.test.ts` | Composer state machine, auto-close | + +## Dependencies + +- **Internal contexts:** `@/context/sdk`, `sync`, `global-sync`, `global-sdk`, `layout`, `settings`, `platform`, `language`, `local`, `file`, `comments`, `prompt`, `permission`, `notification`, `terminal`, `command` +- **Internal components:** `@/components/session`, `@/components/prompt-input`, `@/components/dialog-*`, `@/components/status-popover` +- **UI library:** `@opencode-ai/ui` — ResizeHandle, ScrollView, SessionTurn, InlineInput, Dialog, ContextMenu, HoverCard, DropdownMenu, Button, Icon, Spinner, FileIcon, Markdown, Logo, Toast, Select, DragDrop +- **SDK:** `@opencode-ai/sdk/v2` for Session, Message, Part, UserMessage, AssistantMessage, PermissionRequest, QuestionRequest, Todo, FileDiff types +- **External:** `@thisbeyond/solid-dnd` (drag-and-drop), `@solid-primitives/media` (media queries), `@solid-primitives/resize-observer`, `luxon` (dates) + +## Development Guide + +For Orbit, study these patterns: + +1. **Session page composition** — `session.tsx` shows how to compose message timeline + prompt input + terminal panel + file tabs + review panel in a responsive layout with resize handles. This is the blueprint for Orbit's session view. + +2. **Composer state machine** — `session/composer/session-composer-state.ts` shows how to manage the blocked/unblocked state when the agent requests permissions, asks questions, or creates todos. The auto-close timer pattern is reusable. + +3. **Sidebar architecture** — `layout/sidebar-project.tsx` demonstrates sortable project cards with drag-and-drop, hover cards, context menus, and workspace grouping. Complex multi-concern UI. + +4. **Message timeline** — `session/message-timeline.tsx` is the chat message renderer with virtual scrolling, message actions (fork, copy, revert, comment), and file diff display. + +5. **Error page** — `error.tsx` shows comprehensive `InitError` formatting with cause chain traversal — handles 9+ error types (MCP failures, provider auth, config issues). Reusable pattern for Orbit's error UI. + +## Notes + +- **`layout.tsx` is the largest page** — 400+ lines managing sidebar, tabs, sounds, notifications, deep links, handoff, and theme. Consider decomposition for Orbit. +- **13 test files** across the pages directory — good coverage of pure logic helpers. +- **Responsive design** — `session.tsx` uses `createMediaQuery("(max-width: 600px)")` for mobile layout with tab switching instead of side-by-side panels. +- **Drag-and-drop** — uses `@thisbeyond/solid-dnd` for both project ordering (sidebar) and session tab reordering (titlebar). Orbit would use `@dnd-kit` (React equivalent). +- **`directory-layout.tsx`** is a thin provider wrapper — it scopes SDK, Sync, Local, and Data providers to a specific workspace directory. This pattern maps to Orbit's per-workspace store isolation. +- **Deep links** — `layout/deep-links.ts` handles `opencode://` protocol URLs, routing to the correct project/session. Orbit would need a similar Tauri deep link handler. diff --git a/Agent-backend/packages/app/src/pages/directory-layout.tsx b/Agent-backend/packages/app/src/pages/directory-layout.tsx new file mode 100644 index 000000000..fdf321f2d --- /dev/null +++ b/Agent-backend/packages/app/src/pages/directory-layout.tsx @@ -0,0 +1,93 @@ +import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" +import { createStore } from "solid-js/store" +import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { SDKProvider } from "@/context/sdk" +import { SyncProvider, useSync } from "@/context/sync" +import { LocalProvider } from "@/context/local" +import { useGlobalSDK } from "@/context/global-sdk" + +import { DataProvider } from "@opencode-ai/ui/context" +import { base64Encode } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" +import { showToast } from "@opencode-ai/ui/toast" +import { useLanguage } from "@/context/language" +function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { + const navigate = useNavigate() + const sync = useSync() + const slug = createMemo(() => base64Encode(props.directory)) + + return ( + navigate(`/${slug()}/session/${sessionID}`)} + onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} + > + {props.children} + + ) +} + +export default function Layout(props: ParentProps) { + const params = useParams() + const navigate = useNavigate() + const location = useLocation() + const language = useLanguage() + const globalSDK = useGlobalSDK() + const directory = createMemo(() => decode64(params.dir) ?? "") + const [state, setState] = createStore({ invalid: "", resolved: "" }) + + createEffect(() => { + if (!params.dir) return + const raw = directory() + if (!raw) { + if (state.invalid === params.dir) return + setState("invalid", params.dir) + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + return + } + + const current = params.dir + globalSDK + .createClient({ + directory: raw, + throwOnError: true, + }) + .path.get() + .then((x) => { + if (params.dir !== current) return + const next = x.data?.directory ?? raw + batch(() => { + setState("invalid", "") + setState("resolved", next) + }) + if (next === raw) return + const path = location.pathname.slice(current.length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + .catch(() => { + if (params.dir !== current) return + batch(() => { + setState("invalid", "") + setState("resolved", raw) + }) + }) + }) + + return ( + + {(resolved) => ( + + + {props.children} + + + )} + + ) +} diff --git a/Agent-backend/packages/app/src/pages/error.tsx b/Agent-backend/packages/app/src/pages/error.tsx new file mode 100644 index 000000000..a30d86d18 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/error.tsx @@ -0,0 +1,317 @@ +import { TextField } from "@opencode-ai/ui/text-field" +import { Logo } from "@opencode-ai/ui/logo" +import { Button } from "@opencode-ai/ui/button" +import { Component, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" +import { Icon } from "@opencode-ai/ui/icon" + +export type InitError = { + name: string + data: Record +} + +type Translator = ReturnType["t"] +const CHAIN_SEPARATOR = "\n" + "─".repeat(40) + "\n" + +function isIssue(value: unknown): value is { message: string; path: string[] } { + if (!value || typeof value !== "object") return false + if (!("message" in value) || !("path" in value)) return false + const message = (value as { message: unknown }).message + const path = (value as { path: unknown }).path + if (typeof message !== "string") return false + if (!Array.isArray(path)) return false + return path.every((part) => typeof part === "string") +} + +function isInitError(error: unknown): error is InitError { + return ( + typeof error === "object" && + error !== null && + "name" in error && + "data" in error && + typeof (error as InitError).data === "object" + ) +} + +function safeJson(value: unknown): string { + const seen = new WeakSet() + const json = JSON.stringify( + value, + (_key, val) => { + if (typeof val === "bigint") return val.toString() + if (typeof val === "object" && val) { + if (seen.has(val)) return "[Circular]" + seen.add(val) + } + return val + }, + 2, + ) + return json ?? String(value) +} + +function formatInitError(error: InitError, t: Translator): string { + const data = error.data + switch (error.name) { + case "MCPFailed": { + const name = typeof data.name === "string" ? data.name : "" + return t("error.chain.mcpFailed", { name }) + } + case "ProviderAuthError": { + const providerID = typeof data.providerID === "string" ? data.providerID : "unknown" + const message = typeof data.message === "string" ? data.message : safeJson(data.message) + return t("error.chain.providerAuthFailed", { provider: providerID, message }) + } + case "APIError": { + const message = typeof data.message === "string" ? data.message : t("error.chain.apiError") + const lines: string[] = [message] + + if (typeof data.statusCode === "number") { + lines.push(t("error.chain.status", { status: data.statusCode })) + } + + if (typeof data.isRetryable === "boolean") { + lines.push(t("error.chain.retryable", { retryable: data.isRetryable })) + } + + if (typeof data.responseBody === "string" && data.responseBody) { + lines.push(t("error.chain.responseBody", { body: data.responseBody })) + } + + return lines.join("\n") + } + case "ProviderModelNotFoundError": { + const { providerID, modelID, suggestions } = data as { + providerID: string + modelID: string + suggestions?: string[] + } + + const suggestionsLine = + Array.isArray(suggestions) && suggestions.length + ? [t("error.chain.didYouMean", { suggestions: suggestions.join(", ") })] + : [] + + return [ + t("error.chain.modelNotFound", { provider: providerID, model: modelID }), + ...suggestionsLine, + t("error.chain.checkConfig"), + ].join("\n") + } + case "ProviderInitError": { + const providerID = typeof data.providerID === "string" ? data.providerID : "unknown" + return t("error.chain.providerInitFailed", { provider: providerID }) + } + case "ConfigJsonError": { + const path = typeof data.path === "string" ? data.path : safeJson(data.path) + const message = typeof data.message === "string" ? data.message : "" + if (message) return t("error.chain.configJsonInvalidWithMessage", { path, message }) + return t("error.chain.configJsonInvalid", { path }) + } + case "ConfigDirectoryTypoError": { + const path = typeof data.path === "string" ? data.path : safeJson(data.path) + const dir = typeof data.dir === "string" ? data.dir : safeJson(data.dir) + const suggestion = typeof data.suggestion === "string" ? data.suggestion : safeJson(data.suggestion) + return t("error.chain.configDirectoryTypo", { dir, path, suggestion }) + } + case "ConfigFrontmatterError": { + const path = typeof data.path === "string" ? data.path : safeJson(data.path) + const message = typeof data.message === "string" ? data.message : safeJson(data.message) + return t("error.chain.configFrontmatterError", { path, message }) + } + case "ConfigInvalidError": { + const issues = Array.isArray(data.issues) + ? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) + : [] + const message = typeof data.message === "string" ? data.message : "" + const path = typeof data.path === "string" ? data.path : safeJson(data.path) + + const line = message + ? t("error.chain.configInvalidWithMessage", { path, message }) + : t("error.chain.configInvalid", { path }) + + return [line, ...issues].join("\n") + } + case "UnknownError": + return typeof data.message === "string" ? data.message : safeJson(data) + default: + if (typeof data.message === "string") return data.message + return safeJson(data) + } +} + +function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessage?: string): string { + if (!error) return t("error.chain.unknown") + + if (isInitError(error)) { + const message = formatInitError(error, t) + if (depth > 0 && parentMessage === message) return "" + const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" + return indent + `${error.name}\n${message}` + } + + if (error instanceof Error) { + const isDuplicate = depth > 0 && parentMessage === error.message + const parts: string[] = [] + const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" + + const header = `${error.name}${error.message ? `: ${error.message}` : ""}` + const stack = error.stack?.trim() + + if (stack) { + const startsWithHeader = stack.startsWith(header) + + if (isDuplicate && startsWithHeader) { + const trace = stack.split("\n").slice(1).join("\n").trim() + if (trace) { + parts.push(indent + trace) + } + } + + if (isDuplicate && !startsWithHeader) { + parts.push(indent + stack) + } + + if (!isDuplicate && startsWithHeader) { + parts.push(indent + stack) + } + + if (!isDuplicate && !startsWithHeader) { + parts.push(indent + `${header}\n${stack}`) + } + } + + if (!stack && !isDuplicate) { + parts.push(indent + header) + } + + if (error.cause) { + const causeResult = formatErrorChain(error.cause, t, depth + 1, error.message) + if (causeResult) { + parts.push(causeResult) + } + } + + return parts.join("\n\n") + } + + if (typeof error === "string") { + if (depth > 0 && parentMessage === error) return "" + const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" + return indent + error + } + + const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" + return indent + safeJson(error) +} + +function formatError(error: unknown, t: Translator): string { + return formatErrorChain(error, t, 0) +} + +interface ErrorPageProps { + error: unknown +} + +export const ErrorPage: Component = (props) => { + const platform = usePlatform() + const language = useLanguage() + const [store, setStore] = createStore({ + checking: false, + version: undefined as string | undefined, + actionError: undefined as string | undefined, + }) + + async function checkForUpdates() { + if (!platform.checkUpdate) return + setStore("checking", true) + await platform + .checkUpdate() + .then((result) => { + setStore("actionError", undefined) + if (result.updateAvailable && result.version) setStore("version", result.version) + }) + .catch((err) => { + setStore("actionError", formatError(err, language.t)) + }) + .finally(() => { + setStore("checking", false) + }) + } + + async function installUpdate() { + if (!platform.update || !platform.restart) return + await platform + .update() + .then(() => platform.restart!()) + .then(() => setStore("actionError", undefined)) + .catch((err) => { + setStore("actionError", formatError(err, language.t)) + }) + } + + return ( +
+
+ +
+

{language.t("error.page.title")}

+

{language.t("error.page.description")}

+
+ +
+ + + + {store.checking + ? language.t("error.page.action.checking") + : language.t("error.page.action.checkUpdates")} + + } + > + + + +
+ + {(message) =>

{message()}

} +
+
+
+ {language.t("error.page.report.prefix")} + +
+ + {(version) => ( +

{language.t("error.page.version", { version: version() })}

+ )} +
+
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/pages/home.tsx b/Agent-backend/packages/app/src/pages/home.tsx new file mode 100644 index 000000000..ba3a2b942 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/home.tsx @@ -0,0 +1,131 @@ +import { createMemo, For, Match, Switch } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Logo } from "@opencode-ai/ui/logo" +import { useLayout } from "@/context/layout" +import { useNavigate } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/util/encode" +import { Icon } from "@opencode-ai/ui/icon" +import { usePlatform } from "@/context/platform" +import { DateTime } from "luxon" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectDirectory } from "@/components/dialog-select-directory" +import { DialogSelectServer } from "@/components/dialog-select-server" +import { useServer } from "@/context/server" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" + +export default function Home() { + const sync = useGlobalSync() + const layout = useLayout() + const platform = usePlatform() + const dialog = useDialog() + const navigate = useNavigate() + const server = useServer() + const language = useLanguage() + const homedir = createMemo(() => sync.data.path.home) + const recent = createMemo(() => { + return sync.data.project + .slice() + .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .slice(0, 5) + }) + + const serverDotClass = createMemo(() => { + const healthy = server.healthy() + if (healthy === true) return "bg-icon-success-base" + if (healthy === false) return "bg-icon-critical-base" + return "bg-border-weak-base" + }) + + function openProject(directory: string) { + layout.projects.open(directory) + server.projects.touch(directory) + navigate(`/${base64Encode(directory)}`) + } + + async function chooseProject() { + function resolve(result: string | string[] | null) { + if (Array.isArray(result)) { + for (const directory of result) { + openProject(directory) + } + } else if (result) { + openProject(result) + } + } + + if (platform.openDirectoryPickerDialog && server.isLocal()) { + const result = await platform.openDirectoryPickerDialog?.({ + title: language.t("command.project.open"), + multiple: true, + }) + resolve(result) + } else { + dialog.show( + () => , + () => resolve(null), + ) + } + } + + return ( +
+ + + + 0}> +
+
+
{language.t("home.recentProjects")}
+ +
+
    + + {(project) => ( + + )} + +
+
+
+ +
+ +
+
{language.t("home.empty.title")}
+
{language.t("home.empty.description")}
+
+ +
+
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/pages/layout.tsx b/Agent-backend/packages/app/src/pages/layout.tsx new file mode 100644 index 000000000..052a03c54 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout.tsx @@ -0,0 +1,2341 @@ +import { + batch, + createEffect, + createMemo, + createSignal, + For, + on, + onCleanup, + onMount, + ParentProps, + Show, + untrack, +} from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { useLayout, LocalProject } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { Persist, persisted } from "@/utils/persist" +import { base64Encode } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { getFilename } from "@opencode-ai/util/path" +import { Session, type Message } from "@opencode-ai/sdk/v2/client" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { createStore, produce, reconcile } from "solid-js/store" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { useProviders } from "@/hooks/use-providers" +import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" +import { useGlobalSDK } from "@/context/global-sdk" +import { clearWorkspaceTerminals } from "@/context/terminal" +import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" +import { useNotification } from "@/context/notification" +import { usePermission } from "@/context/permission" +import { Binary } from "@opencode-ai/util/binary" +import { retry } from "@opencode-ai/util/retry" +import { playSound, soundSrc } from "@/utils/sound" +import { createAim } from "@/utils/aim" +import { setNavigate } from "@/utils/notification-click" +import { Worktree as WorktreeState } from "@/utils/worktree" +import { setSessionHandoff } from "@/pages/session/handoff" + +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { DialogSelectProvider } from "@/components/dialog-select-provider" +import { DialogSelectServer } from "@/components/dialog-select-server" +import { DialogSettings } from "@/components/dialog-settings" +import { useCommand, type CommandOption } from "@/context/command" +import { ConstrainDragXAxis } from "@/utils/solid-dnd" +import { DialogSelectDirectory } from "@/components/dialog-select-directory" +import { DialogEditProject } from "@/components/dialog-edit-project" +import { DebugBar } from "@/components/debug-bar" +import { Titlebar } from "@/components/titlebar" +import { useServer } from "@/context/server" +import { useLanguage, type Locale } from "@/context/language" +import { + displayName, + effectiveWorkspaceOrder, + errorMessage, + getDraggableId, + latestRootSession, + sortedRootSessions, + workspaceKey, +} from "./layout/helpers" +import { + collectNewSessionDeepLinks, + collectOpenProjectDeepLinks, + deepLinkEvent, + drainPendingDeepLinks, +} from "./layout/deep-links" +import { createInlineEditorController } from "./layout/inline-editor" +import { + LocalWorkspace, + SortableWorkspace, + WorkspaceDragOverlay, + type WorkspaceSidebarContext, +} from "./layout/sidebar-workspace" +import { workspaceOpenState } from "./layout/sidebar-workspace-helpers" +import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" +import { SidebarContent } from "./layout/sidebar-shell" + +export default function Layout(props: ParentProps) { + const [store, setStore, , ready] = persisted( + Persist.global("layout.page", ["layout.page.v1"]), + createStore({ + lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } }, + activeProject: undefined as string | undefined, + activeWorkspace: undefined as string | undefined, + workspaceOrder: {} as Record, + workspaceName: {} as Record, + workspaceBranchName: {} as Record>, + workspaceExpanded: {} as Record, + gettingStartedDismissed: false, + }), + ) + + const pageReady = createMemo(() => ready()) + + let scrollContainerRef: HTMLDivElement | undefined + + const params = useParams() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const layout = useLayout() + const layoutReady = createMemo(() => layout.ready()) + const platform = usePlatform() + const settings = useSettings() + const server = useServer() + const notification = useNotification() + const permission = usePermission() + const navigate = useNavigate() + setNavigate(navigate) + const providers = useProviders() + const dialog = useDialog() + const command = useCommand() + const theme = useTheme() + const language = useLanguage() + const initialDirectory = decode64(params.dir) + const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] + const colorSchemeKey: Record = { + system: "theme.scheme.system", + light: "theme.scheme.light", + dark: "theme.scheme.dark", + } + const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) + const currentDir = createMemo(() => decode64(params.dir) ?? "") + + const [state, setState] = createStore({ + autoselect: !initialDirectory, + busyWorkspaces: {} as Record, + hoverSession: undefined as string | undefined, + hoverProject: undefined as string | undefined, + scrollSessionKey: undefined as string | undefined, + nav: undefined as HTMLElement | undefined, + }) + + const editor = createInlineEditorController() + const setBusy = (directory: string, value: boolean) => { + const key = workspaceKey(directory) + if (value) { + setState("busyWorkspaces", key, true) + return + } + setState( + "busyWorkspaces", + produce((draft) => { + delete draft[key] + }), + ) + } + const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)] + const navLeave = { current: undefined as number | undefined } + const [sortNow, setSortNow] = createSignal(Date.now()) + const [sizing, setSizing] = createSignal(false) + let sizet: number | undefined + let sortNowInterval: ReturnType | undefined + const sortNowTimeout = setTimeout( + () => { + setSortNow(Date.now()) + sortNowInterval = setInterval(() => setSortNow(Date.now()), 60_000) + }, + 60_000 - (Date.now() % 60_000), + ) + + const aim = createAim({ + enabled: () => !layout.sidebar.opened(), + active: () => state.hoverProject, + el: () => state.nav?.querySelector("[data-component='sidebar-rail']") ?? state.nav, + onActivate: (directory) => { + globalSync.child(directory) + setState("hoverProject", directory) + setState("hoverSession", undefined) + }, + }) + + onCleanup(() => { + if (navLeave.current !== undefined) clearTimeout(navLeave.current) + clearTimeout(sortNowTimeout) + if (sortNowInterval) clearInterval(sortNowInterval) + if (sizet !== undefined) clearTimeout(sizet) + if (peekt !== undefined) clearTimeout(peekt) + aim.reset() + }) + + onMount(() => { + const stop = () => setSizing(false) + window.addEventListener("pointerup", stop) + window.addEventListener("pointercancel", stop) + window.addEventListener("blur", stop) + onCleanup(() => { + window.removeEventListener("pointerup", stop) + window.removeEventListener("pointercancel", stop) + window.removeEventListener("blur", stop) + }) + }) + + const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) + const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) + const setHoverProject = (value: string | undefined) => { + setState("hoverProject", value) + if (value !== undefined) return + aim.reset() + } + const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined)) + const setHoverSession = (id: string | undefined) => setState("hoverSession", id) + + const disarm = () => { + if (navLeave.current === undefined) return + clearTimeout(navLeave.current) + navLeave.current = undefined + } + + const arm = () => { + if (layout.sidebar.opened()) return + if (state.hoverProject === undefined) return + disarm() + navLeave.current = window.setTimeout(() => { + navLeave.current = undefined + setHoverProject(undefined) + setState("hoverSession", undefined) + }, 300) + } + + const [peek, setPeek] = createSignal(undefined) + const [peeked, setPeeked] = createSignal(false) + let peekt: number | undefined + + const hoverProjectData = createMemo(() => { + const id = state.hoverProject + if (!id) return + return layout.projects.list().find((project) => project.worktree === id) + }) + + createEffect(() => { + const p = hoverProjectData() + if (p) { + if (peekt !== undefined) { + clearTimeout(peekt) + peekt = undefined + } + setPeek(p) + setPeeked(true) + return + } + + setPeeked(false) + if (peek() === undefined) return + if (peekt !== undefined) clearTimeout(peekt) + peekt = window.setTimeout(() => { + peekt = undefined + setPeek(undefined) + }, 180) + }) + + createEffect(() => { + if (!layout.sidebar.opened()) return + setHoverProject(undefined) + }) + + const autoselecting = createMemo(() => { + if (params.dir) return false + if (!state.autoselect) return false + if (!pageReady()) return true + if (!layoutReady()) return true + const list = layout.projects.list() + if (list.length > 0) return true + return !!server.projects.last() + }) + + createEffect(() => { + if (!state.autoselect) return + const dir = params.dir + if (!dir) return + const directory = decode64(dir) + if (!directory) return + setState("autoselect", false) + }) + + const editorOpen = editor.editorOpen + const openEditor = editor.openEditor + const closeEditor = editor.closeEditor + const setEditor = editor.setEditor + const InlineEditor = editor.InlineEditor + + const clearSidebarHoverState = () => { + if (layout.sidebar.opened()) return + setState("hoverSession", undefined) + setHoverProject(undefined) + } + + const navigateWithSidebarReset = (href: string) => { + clearSidebarHoverState() + navigate(href) + layout.mobileSidebar.hide() + } + + function cycleTheme(direction = 1) { + const ids = availableThemeEntries().map(([id]) => id) + if (ids.length === 0) return + const currentIndex = ids.indexOf(theme.themeId()) + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length + const nextThemeId = ids[nextIndex] + theme.setTheme(nextThemeId) + const nextTheme = theme.themes()[nextThemeId] + showToast({ + title: language.t("toast.theme.title"), + description: nextTheme?.name ?? nextThemeId, + }) + } + + function cycleColorScheme(direction = 1) { + const current = theme.colorScheme() + const currentIndex = colorSchemeOrder.indexOf(current) + const nextIndex = + currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length + const next = colorSchemeOrder[nextIndex] + theme.setColorScheme(next) + showToast({ + title: language.t("toast.scheme.title"), + description: colorSchemeLabel(next), + }) + } + + function setLocale(next: Locale) { + if (next === language.locale()) return + language.setLocale(next) + showToast({ + title: language.t("toast.language.title"), + description: language.t("toast.language.description", { language: language.label(next) }), + }) + } + + function cycleLanguage(direction = 1) { + const locales = language.locales + const currentIndex = locales.indexOf(language.locale()) + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + locales.length) % locales.length + const next = locales[nextIndex] + if (!next) return + setLocale(next) + } + + const useUpdatePolling = () => + onMount(() => { + if (!platform.checkUpdate || !platform.update || !platform.restart) return + + let toastId: number | undefined + let interval: ReturnType | undefined + + const pollUpdate = () => + platform.checkUpdate!().then(({ updateAvailable, version }) => { + if (!updateAvailable) return + if (toastId !== undefined) return + toastId = showToast({ + persistent: true, + icon: "download", + title: language.t("toast.update.title"), + description: language.t("toast.update.description", { version: version ?? "" }), + actions: [ + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() + }, + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss", + }, + ], + }) + }) + + createEffect(() => { + if (!settings.ready()) return + + if (!settings.updates.startup()) { + if (interval === undefined) return + clearInterval(interval) + interval = undefined + return + } + + if (interval !== undefined) return + void pollUpdate() + interval = setInterval(pollUpdate, 10 * 60 * 1000) + }) + + onCleanup(() => { + if (interval === undefined) return + clearInterval(interval) + }) + }) + + const useSDKNotificationToasts = () => + onMount(() => { + const toastBySession = new Map() + const alertedAtBySession = new Map() + const cooldownMs = 5000 + + const dismissSessionAlert = (sessionKey: string) => { + const toastId = toastBySession.get(sessionKey) + if (toastId === undefined) return + toaster.dismiss(toastId) + toastBySession.delete(sessionKey) + alertedAtBySession.delete(sessionKey) + } + + const unsub = globalSDK.event.listen((e) => { + if (e.details?.type === "worktree.ready") { + setBusy(e.name, false) + WorktreeState.ready(e.name) + return + } + + if (e.details?.type === "worktree.failed") { + setBusy(e.name, false) + WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed")) + return + } + + if ( + e.details?.type === "question.replied" || + e.details?.type === "question.rejected" || + e.details?.type === "permission.replied" + ) { + const props = e.details.properties as { sessionID: string } + const sessionKey = `${e.name}:${props.sessionID}` + dismissSessionAlert(sessionKey) + return + } + + if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return + const title = + e.details.type === "permission.asked" + ? language.t("notification.permission.title") + : language.t("notification.question.title") + const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const) + const directory = e.name + const props = e.details.properties + if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return + + const [store] = globalSync.child(directory, { bootstrap: false }) + const session = store.session.find((s) => s.id === props.sessionID) + const sessionKey = `${directory}:${props.sessionID}` + + const sessionTitle = session?.title ?? language.t("command.session.new") + const projectName = getFilename(directory) + const description = + e.details.type === "permission.asked" + ? language.t("notification.permission.description", { sessionTitle, projectName }) + : language.t("notification.question.description", { sessionTitle, projectName }) + const href = `/${base64Encode(directory)}/session/${props.sessionID}` + + const now = Date.now() + const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 + if (now - lastAlerted < cooldownMs) return + alertedAtBySession.set(sessionKey, now) + + if (e.details.type === "permission.asked") { + if (settings.sounds.permissionsEnabled()) { + playSound(soundSrc(settings.sounds.permissions())) + } + if (settings.notifications.permissions()) { + void platform.notify(title, description, href) + } + } + + if (e.details.type === "question.asked") { + if (settings.notifications.agent()) { + void platform.notify(title, description, href) + } + } + + const currentSession = params.id + if (directory === currentDir() && props.sessionID === currentSession) return + if (directory === currentDir() && session?.parentID === currentSession) return + + dismissSessionAlert(sessionKey) + + const toastId = showToast({ + persistent: true, + icon, + title, + description, + actions: [ + { + label: language.t("notification.action.goToSession"), + onClick: () => navigate(href), + }, + { + label: language.t("common.dismiss"), + onClick: "dismiss", + }, + ], + }) + toastBySession.set(sessionKey, toastId) + }) + onCleanup(unsub) + + createEffect(() => { + const currentSession = params.id + if (!currentDir() || !currentSession) return + const sessionKey = `${currentDir()}:${currentSession}` + dismissSessionAlert(sessionKey) + const [store] = globalSync.child(currentDir(), { bootstrap: false }) + const childSessions = store.session.filter((s) => s.parentID === currentSession) + for (const child of childSessions) { + dismissSessionAlert(`${currentDir()}:${child.id}`) + } + }) + }) + + useUpdatePolling() + useSDKNotificationToasts() + + function scrollToSession(sessionId: string, sessionKey: string) { + if (!scrollContainerRef) return + if (state.scrollSessionKey === sessionKey) return + const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) + if (!element) return + const containerRect = scrollContainerRef.getBoundingClientRect() + const elementRect = element.getBoundingClientRect() + if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) { + setState("scrollSessionKey", sessionKey) + return + } + setState("scrollSessionKey", sessionKey) + element.scrollIntoView({ block: "nearest", behavior: "smooth" }) + } + + const currentProject = createMemo(() => { + const directory = currentDir() + if (!directory) return + + const projects = layout.projects.list() + + const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) + if (sandbox) return sandbox + + const direct = projects.find((p) => p.worktree === directory) + if (direct) return direct + + const [child] = globalSync.child(directory, { bootstrap: false }) + const id = child.project + if (!id) return + + const meta = globalSync.data.project.find((p) => p.id === id) + const root = meta?.worktree + if (!root) return + + return projects.find((p) => p.worktree === root) + }) + + createEffect( + on( + () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }), + (value) => { + if (!value.ready) return + if (!value.layoutReady) return + if (!state.autoselect) return + if (value.dir) return + + const last = server.projects.last() + + if (value.list.length === 0) { + if (!last) return + setState("autoselect", false) + openProject(last, false) + navigateToProject(last) + return + } + + const next = value.list.find((project) => project.worktree === last) ?? value.list[0] + if (!next) return + setState("autoselect", false) + openProject(next.worktree, false) + navigateToProject(next.worktree) + }, + ), + ) + + const workspaceName = (directory: string, projectId?: string, branch?: string) => { + const key = workspaceKey(directory) + const direct = store.workspaceName[key] ?? store.workspaceName[directory] + if (direct) return direct + if (!projectId) return + if (!branch) return + return store.workspaceBranchName[projectId]?.[branch] + } + + const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { + const key = workspaceKey(directory) + setStore("workspaceName", key, next) + if (!projectId) return + if (!branch) return + if (!store.workspaceBranchName[projectId]) { + setStore("workspaceBranchName", projectId, {}) + } + setStore("workspaceBranchName", projectId, branch, next) + } + + const workspaceLabel = (directory: string, branch?: string, projectId?: string) => + workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) + + const workspaceSetting = createMemo(() => { + const project = currentProject() + if (!project) return false + if (project.vcs !== "git") return false + return layout.sidebar.workspaces(project.worktree)() + }) + + const visibleSessionDirs = createMemo(() => { + const project = currentProject() + if (!project) return [] as string[] + if (!workspaceSetting()) return [project.worktree] + + const activeDir = currentDir() + return workspaceIds(project).filter((directory) => { + const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree + const active = directory === activeDir + return expanded || active + }) + }) + + createEffect(() => { + if (!pageReady()) return + if (!layoutReady()) return + const projects = layout.projects.list() + for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { + if (!expanded) continue + const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + if (!project) continue + if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue + setStore("workspaceExpanded", directory, false) + } + }) + + const currentSessions = createMemo(() => { + const now = Date.now() + const dirs = visibleSessionDirs() + if (dirs.length === 0) return [] as Session[] + + const result: Session[] = [] + for (const dir of dirs) { + const [dirStore] = globalSync.child(dir, { bootstrap: true }) + const dirSessions = sortedRootSessions(dirStore, now) + result.push(...dirSessions) + } + return result + }) + + type PrefetchQueue = { + inflight: Set + pending: string[] + pendingSet: Set + running: number + } + + const prefetchChunk = 200 + const prefetchConcurrency = 1 + const prefetchPendingLimit = 6 + const prefetchToken = { value: 0 } + const prefetchQueues = new Map() + + const PREFETCH_MAX_SESSIONS_PER_DIR = 10 + const prefetchedByDir = new Map>() + + const lruFor = (directory: string) => { + const existing = prefetchedByDir.get(directory) + if (existing) return existing + const created = new Set() + prefetchedByDir.set(directory, created) + return created + } + + const markPrefetched = (directory: string, sessionID: string) => { + const lru = lruFor(directory) + return pickSessionCacheEvictions({ + seen: lru, + keep: sessionID, + limit: PREFETCH_MAX_SESSIONS_PER_DIR, + preserve: directory === params.dir && params.id ? [params.id] : undefined, + }) + } + + createEffect(() => { + params.dir + globalSDK.url + + prefetchToken.value += 1 + for (const q of prefetchQueues.values()) { + q.pending.length = 0 + q.pendingSet.clear() + } + }) + + const queueFor = (directory: string) => { + const existing = prefetchQueues.get(directory) + if (existing) return existing + + const created: PrefetchQueue = { + inflight: new Set(), + pending: [], + pendingSet: new Set(), + running: 0, + } + prefetchQueues.set(directory, created) + return created + } + + const mergeByID = (current: T[], incoming: T[]) => { + if (current.length === 0) { + return incoming.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + + const map = new Map() + for (const item of current) { + map.set(item.id, item) + } + for (const item of incoming) { + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + + async function prefetchMessages(directory: string, sessionID: string, token: number) { + const [store, setStore] = globalSync.child(directory, { bootstrap: false }) + + return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) + .then((messages) => { + if (prefetchToken.value !== token) return + if (!lruFor(directory).has(sessionID)) return + + const items = (messages.data ?? []).filter((x) => !!x?.info?.id) + const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) + const sorted = mergeByID([], next) + + const current = store.message[sessionID] ?? [] + const merged = mergeByID( + current.filter((item): item is Message => !!item?.id), + sorted, + ) + + batch(() => { + setStore("message", sessionID, reconcile(merged, { key: "id" })) + + for (const message of items) { + const currentParts = store.part[message.info.id] ?? [] + const mergedParts = mergeByID( + currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id), + message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id), + ) + + setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) + } + }) + }) + .catch(() => undefined) + } + + const pumpPrefetch = (directory: string) => { + const q = queueFor(directory) + if (q.running >= prefetchConcurrency) return + + const sessionID = q.pending.shift() + if (!sessionID) return + + q.pendingSet.delete(sessionID) + q.inflight.add(sessionID) + q.running += 1 + + const token = prefetchToken.value + + void prefetchMessages(directory, sessionID, token).finally(() => { + q.running -= 1 + q.inflight.delete(sessionID) + pumpPrefetch(directory) + }) + } + + const prefetchSession = (session: Session, priority: "high" | "low" = "low") => { + const directory = session.directory + if (!directory) return + + const [store] = globalSync.child(directory, { bootstrap: false }) + const cached = untrack(() => store.message[session.id] !== undefined) + if (cached) return + + const q = queueFor(directory) + if (q.inflight.has(session.id)) return + if (q.pendingSet.has(session.id)) return + + const lru = lruFor(directory) + const known = lru.has(session.id) + if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return + const stale = markPrefetched(directory, session.id) + if (stale.length > 0) { + const [, setStore] = globalSync.child(directory, { bootstrap: false }) + for (const id of stale) { + globalSync.todo.set(id, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, stale) + }), + ) + } + + if (priority === "high") q.pending.unshift(session.id) + if (priority !== "high") q.pending.push(session.id) + q.pendingSet.add(session.id) + + while (q.pending.length > prefetchPendingLimit) { + const dropped = q.pending.pop() + if (!dropped) continue + q.pendingSet.delete(dropped) + } + + pumpPrefetch(directory) + } + + createEffect(() => { + const sessions = currentSessions() + const id = params.id + + if (!id) { + const first = sessions[0] + if (first) prefetchSession(first) + + const second = sessions[1] + if (second) prefetchSession(second) + return + } + + const index = sessions.findIndex((s) => s.id === id) + if (index === -1) return + + const next = sessions[index + 1] + if (next) prefetchSession(next) + + const prev = sessions[index - 1] + if (prev) prefetchSession(prev) + }) + + function navigateSessionByOffset(offset: number) { + const sessions = currentSessions() + if (sessions.length === 0) return + + const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 + + let targetIndex: number + if (sessionIndex === -1) { + targetIndex = offset > 0 ? 0 : sessions.length - 1 + } else { + targetIndex = (sessionIndex + offset + sessions.length) % sessions.length + } + + const session = sessions[targetIndex] + if (!session) return + + const next = sessions[(targetIndex + 1) % sessions.length] + const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length] + + if (offset > 0) { + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) + } + + if (offset < 0) { + if (prev) prefetchSession(prev, "high") + if (next) prefetchSession(next) + } + + navigateToSession(session) + } + + function navigateSessionByUnseen(offset: number) { + const sessions = currentSessions() + if (sessions.length === 0) return + + const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0) + if (!hasUnseen) return + + const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 + const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex + + for (let i = 1; i <= sessions.length; i++) { + const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length + const session = sessions[index] + if (!session) continue + if (notification.session.unseenCount(session.id) === 0) continue + + prefetchSession(session, "high") + + const next = sessions[(index + 1) % sessions.length] + const prev = sessions[(index - 1 + sessions.length) % sessions.length] + + if (offset > 0) { + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) + } + + if (offset < 0) { + if (prev) prefetchSession(prev, "high") + if (next) prefetchSession(next) + } + + navigateToSession(session) + return + } + } + + async function archiveSession(session: Session) { + const [store, setStore] = globalSync.child(session.directory) + const sessions = store.session ?? [] + const index = sessions.findIndex((s) => s.id === session.id) + const nextSession = sessions[index + 1] ?? sessions[index - 1] + + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + time: { archived: Date.now() }, + }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + if (session.id === params.id) { + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + } else { + navigate(`/${params.dir}/session`) + } + } + } + + command.register("layout", () => { + const commands: CommandOption[] = [ + { + id: "sidebar.toggle", + title: language.t("command.sidebar.toggle"), + category: language.t("command.category.view"), + keybind: "mod+b", + onSelect: () => layout.sidebar.toggle(), + }, + { + id: "project.open", + title: language.t("command.project.open"), + category: language.t("command.category.project"), + keybind: "mod+o", + onSelect: () => chooseProject(), + }, + { + id: "provider.connect", + title: language.t("command.provider.connect"), + category: language.t("command.category.provider"), + onSelect: () => connectProvider(), + }, + { + id: "server.switch", + title: language.t("command.server.switch"), + category: language.t("command.category.server"), + onSelect: () => openServer(), + }, + { + id: "settings.open", + title: language.t("command.settings.open"), + category: language.t("command.category.settings"), + keybind: "mod+comma", + onSelect: () => openSettings(), + }, + { + id: "session.previous", + title: language.t("command.session.previous"), + category: language.t("command.category.session"), + keybind: "alt+arrowup", + onSelect: () => navigateSessionByOffset(-1), + }, + { + id: "session.next", + title: language.t("command.session.next"), + category: language.t("command.category.session"), + keybind: "alt+arrowdown", + onSelect: () => navigateSessionByOffset(1), + }, + { + id: "session.previous.unseen", + title: language.t("command.session.previous.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowup", + onSelect: () => navigateSessionByUnseen(-1), + }, + { + id: "session.next.unseen", + title: language.t("command.session.next.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowdown", + onSelect: () => navigateSessionByUnseen(1), + }, + { + id: "session.archive", + title: language.t("command.session.archive"), + category: language.t("command.category.session"), + keybind: "mod+shift+backspace", + disabled: !params.dir || !params.id, + onSelect: () => { + const session = currentSessions().find((s) => s.id === params.id) + if (session) archiveSession(session) + }, + }, + { + id: "workspace.new", + title: language.t("workspace.new"), + category: language.t("command.category.workspace"), + keybind: "mod+shift+w", + disabled: !workspaceSetting(), + onSelect: () => { + const project = currentProject() + if (!project) return + return createWorkspace(project) + }, + }, + { + id: "workspace.toggle", + title: language.t("command.workspace.toggle"), + description: language.t("command.workspace.toggle.description"), + category: language.t("command.category.workspace"), + slash: "workspace", + disabled: !currentProject() || currentProject()?.vcs !== "git", + onSelect: () => { + const project = currentProject() + if (!project) return + if (project.vcs !== "git") return + const wasEnabled = layout.sidebar.workspaces(project.worktree)() + layout.sidebar.toggleWorkspaces(project.worktree) + showToast({ + title: wasEnabled + ? language.t("toast.workspace.disabled.title") + : language.t("toast.workspace.enabled.title"), + description: wasEnabled + ? language.t("toast.workspace.disabled.description") + : language.t("toast.workspace.enabled.description"), + }) + }, + }, + { + id: "theme.cycle", + title: language.t("command.theme.cycle"), + category: language.t("command.category.theme"), + keybind: "mod+shift+t", + onSelect: () => cycleTheme(1), + }, + ] + + for (const [id, definition] of availableThemeEntries()) { + commands.push({ + id: `theme.set.${id}`, + title: language.t("command.theme.set", { theme: definition.name ?? id }), + category: language.t("command.category.theme"), + onSelect: () => theme.commitPreview(), + onHighlight: () => { + theme.previewTheme(id) + return () => theme.cancelPreview() + }, + }) + } + + commands.push({ + id: "theme.scheme.cycle", + title: language.t("command.theme.scheme.cycle"), + category: language.t("command.category.theme"), + keybind: "mod+shift+s", + onSelect: () => cycleColorScheme(1), + }) + + for (const scheme of colorSchemeOrder) { + commands.push({ + id: `theme.scheme.${scheme}`, + title: language.t("command.theme.scheme.set", { scheme: colorSchemeLabel(scheme) }), + category: language.t("command.category.theme"), + onSelect: () => theme.commitPreview(), + onHighlight: () => { + theme.previewColorScheme(scheme) + return () => theme.cancelPreview() + }, + }) + } + + commands.push({ + id: "language.cycle", + title: language.t("command.language.cycle"), + category: language.t("command.category.language"), + onSelect: () => cycleLanguage(1), + }) + + for (const locale of language.locales) { + commands.push({ + id: `language.set.${locale}`, + title: language.t("command.language.set", { language: language.label(locale) }), + category: language.t("command.category.language"), + onSelect: () => setLocale(locale), + }) + } + + return commands + }) + + function connectProvider() { + dialog.show(() => ) + } + + function openServer() { + dialog.show(() => ) + } + + function openSettings() { + dialog.show(() => ) + } + + function projectRoot(directory: string) { + const project = layout.projects + .list() + .find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + if (project) return project.worktree + + const known = Object.entries(store.workspaceOrder).find( + ([root, dirs]) => root === directory || dirs.includes(directory), + ) + if (known) return known[0] + + const [child] = globalSync.child(directory, { bootstrap: false }) + const id = child.project + if (!id) return directory + + const meta = globalSync.data.project.find((item) => item.id === id) + return meta?.worktree ?? directory + } + + function activeProjectRoot(directory: string) { + return currentProject()?.worktree ?? projectRoot(directory) + } + + function touchProjectRoute() { + const root = currentProject()?.worktree + if (!root) return + if (server.projects.last() !== root) server.projects.touch(root) + return root + } + + function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { + setStore("lastProjectSession", root, { directory, id, at: Date.now() }) + return root + } + + function clearLastProjectSession(root: string) { + if (!store.lastProjectSession[root]) return + setStore( + "lastProjectSession", + produce((draft) => { + delete draft[root] + }), + ) + } + + function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { + rememberSessionRoute(directory, id, root) + notification.session.markViewed(id) + const expanded = untrack(() => store.workspaceExpanded[directory]) + if (expanded === false) { + setStore("workspaceExpanded", directory, true) + } + requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`)) + return root + } + + async function navigateToProject(directory: string | undefined) { + if (!directory) return + const root = projectRoot(directory) + server.projects.touch(root) + const project = layout.projects.list().find((item) => item.worktree === root) + let dirs = project + ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) + : [root] + const canOpen = (value: string | undefined) => { + if (!value) return false + return dirs.some((item) => workspaceKey(item) === workspaceKey(value)) + } + const refreshDirs = async (target?: string) => { + if (!target || target === root || canOpen(target)) return canOpen(target) + const listed = await globalSDK.client.worktree + .list({ directory: root }) + .then((x) => x.data ?? []) + .catch(() => [] as string[]) + dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) + return canOpen(target) + } + const openSession = async (target: { directory: string; id: string }) => { + if (!canOpen(target.directory)) return false + const [data] = globalSync.child(target.directory, { bootstrap: false }) + if (data.session.some((item) => item.id === target.id)) { + setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() }) + navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`) + return true + } + const resolved = await globalSDK.client.session + .get({ sessionID: target.id }) + .then((x) => x.data) + .catch(() => undefined) + if (!resolved?.directory) return false + if (!canOpen(resolved.directory)) return false + setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() }) + navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`) + return true + } + + const projectSession = store.lastProjectSession[root] + if (projectSession?.id) { + await refreshDirs(projectSession.directory) + const opened = await openSession(projectSession) + if (opened) return + clearLastProjectSession(root) + } + + const latest = latestRootSession( + dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]), + Date.now(), + ) + if (latest && (await openSession(latest))) { + return + } + + const fetched = latestRootSession( + await Promise.all( + dirs.map(async (item) => ({ + path: { directory: item }, + session: await globalSDK.client.session + .list({ directory: item }) + .then((x) => x.data ?? []) + .catch(() => []), + })), + ), + Date.now(), + ) + if (fetched && (await openSession(fetched))) { + return + } + + navigateWithSidebarReset(`/${base64Encode(root)}/session`) + } + + function navigateToSession(session: Session | undefined) { + if (!session) return + navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`) + } + + function openProject(directory: string, navigate = true) { + layout.projects.open(directory) + if (navigate) navigateToProject(directory) + } + + const handleDeepLinks = (urls: string[]) => { + if (!server.isLocal()) return + + for (const directory of collectOpenProjectDeepLinks(urls)) { + openProject(directory) + } + + for (const link of collectNewSessionDeepLinks(urls)) { + openProject(link.directory, false) + const slug = base64Encode(link.directory) + if (link.prompt) { + setSessionHandoff(slug, { prompt: link.prompt }) + } + const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session` + navigateWithSidebarReset(href) + } + } + + onMount(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ urls: string[] }>).detail + const urls = detail?.urls ?? [] + if (urls.length === 0) return + handleDeepLinks(urls) + } + + handleDeepLinks(drainPendingDeepLinks(window)) + window.addEventListener(deepLinkEvent, handler as EventListener) + onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener)) + }) + + async function renameProject(project: LocalProject, next: string) { + const current = displayName(project) + if (next === current) return + const name = next === getFilename(project.worktree) ? "" : next + + if (project.id && project.id !== "global") { + await globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name }) + return + } + + globalSync.project.meta(project.worktree, { name }) + } + + const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => { + const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) + if (current === next) return + setWorkspaceName(directory, next, projectId, branch) + } + + function closeProject(directory: string) { + const list = layout.projects.list() + const index = list.findIndex((x) => x.worktree === directory) + const active = currentProject()?.worktree === directory + if (index === -1) return + const next = list[index + 1] + + if (!active) { + layout.projects.close(directory) + return + } + + if (!next) { + layout.projects.close(directory) + navigate("/") + return + } + + navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`) + layout.projects.close(directory) + queueMicrotask(() => { + void navigateToProject(next.worktree) + }) + } + + function toggleProjectWorkspaces(project: LocalProject) { + const enabled = layout.sidebar.workspaces(project.worktree)() + if (enabled) { + layout.sidebar.toggleWorkspaces(project.worktree) + return + } + if (project.vcs !== "git") return + layout.sidebar.toggleWorkspaces(project.worktree) + } + + const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) + + async function chooseProject() { + function resolve(result: string | string[] | null) { + if (Array.isArray(result)) { + for (const directory of result) { + openProject(directory, false) + } + navigateToProject(result[0]) + } else if (result) { + openProject(result) + } + } + + if (platform.openDirectoryPickerDialog && server.isLocal()) { + const result = await platform.openDirectoryPickerDialog?.({ + title: language.t("command.project.open"), + multiple: true, + }) + resolve(result) + } else { + dialog.show( + () => , + () => resolve(null), + ) + } + } + + const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => { + if (directory === root) return + + const current = currentDir() + const currentKey = workspaceKey(current) + const deletedKey = workspaceKey(directory) + const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey) + if (!leaveDeletedWorkspace && shouldLeave) { + navigateWithSidebarReset(`/${base64Encode(root)}/session`) + } + + setBusy(directory, true) + + const result = await globalSDK.client.worktree + .remove({ directory: root, worktreeRemoveInput: { directory } }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("workspace.delete.failed.title"), + description: errorMessage(err, language.t("common.requestFailed")), + }) + return false + }) + + setBusy(directory, false) + + if (!result) return + + if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) { + clearLastProjectSession(root) + } + + globalSync.set( + "project", + produce((draft) => { + const project = draft.find((item) => item.worktree === root) + if (!project) return + project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory) + }), + ) + setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory)) + + layout.projects.close(directory) + layout.projects.open(root) + + if (shouldLeave) return + + const nextCurrent = currentDir() + const nextKey = workspaceKey(nextCurrent) + const project = layout.projects.list().find((item) => item.worktree === root) + const dirs = project + ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) + : [root] + const valid = dirs.some((item) => workspaceKey(item) === nextKey) + + if (params.dir && projectRoot(nextCurrent) === root && !valid) { + navigateWithSidebarReset(`/${base64Encode(root)}/session`) + } + } + + const resetWorkspace = async (root: string, directory: string) => { + if (directory === root) return + setBusy(directory, true) + + const progress = showToast({ + persistent: true, + title: language.t("workspace.resetting.title"), + description: language.t("workspace.resetting.description"), + }) + const dismiss = () => toaster.dismiss(progress) + + const sessions: Session[] = await globalSDK.client.session + .list({ directory }) + .then((x) => x.data ?? []) + .catch(() => []) + + clearWorkspaceTerminals( + directory, + sessions.map((s) => s.id), + platform, + ) + await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) + + const result = await globalSDK.client.worktree + .reset({ directory: root, worktreeResetInput: { directory } }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("workspace.reset.failed.title"), + description: errorMessage(err, language.t("common.requestFailed")), + }) + return false + }) + + if (!result) { + setBusy(directory, false) + dismiss() + return + } + + const archivedAt = Date.now() + await Promise.all( + sessions + .filter((session) => session.time.archived === undefined) + .map((session) => + globalSDK.client.session + .update({ + sessionID: session.id, + directory: session.directory, + time: { archived: archivedAt }, + }) + .catch(() => undefined), + ), + ) + + setBusy(directory, false) + dismiss() + + showToast({ + title: language.t("workspace.reset.success.title"), + description: language.t("workspace.reset.success.description"), + actions: [ + { + label: language.t("command.session.new"), + onClick: () => { + const href = `/${base64Encode(directory)}/session` + navigate(href) + layout.mobileSidebar.hide() + }, + }, + { + label: language.t("common.dismiss"), + onClick: "dismiss", + }, + ], + }) + } + + function DialogDeleteWorkspace(props: { root: string; directory: string }) { + const name = createMemo(() => getFilename(props.directory)) + const [data, setData] = createStore({ + status: "loading" as "loading" | "ready" | "error", + dirty: false, + }) + + onMount(() => { + globalSDK.client.file + .status({ directory: props.directory }) + .then((x) => { + const files = x.data ?? [] + const dirty = files.length > 0 + setData({ status: "ready", dirty }) + }) + .catch(() => { + setData({ status: "error", dirty: false }) + }) + }) + + const handleDelete = () => { + const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory) + if (leaveDeletedWorkspace) { + navigateWithSidebarReset(`/${base64Encode(props.root)}/session`) + } + dialog.close() + void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace) + } + + const description = () => { + if (data.status === "loading") return language.t("workspace.status.checking") + if (data.status === "error") return language.t("workspace.status.error") + if (!data.dirty) return language.t("workspace.status.clean") + return language.t("workspace.status.dirty") + } + + return ( + +
+
+ + {language.t("workspace.delete.confirm", { name: name() })} + + {description()} +
+
+ + +
+
+
+ ) + } + + function DialogResetWorkspace(props: { root: string; directory: string }) { + const name = createMemo(() => getFilename(props.directory)) + const [state, setState] = createStore({ + status: "loading" as "loading" | "ready" | "error", + dirty: false, + sessions: [] as Session[], + }) + + const refresh = async () => { + const sessions = await globalSDK.client.session + .list({ directory: props.directory }) + .then((x) => x.data ?? []) + .catch(() => []) + const active = sessions.filter((session) => session.time.archived === undefined) + setState({ sessions: active }) + } + + onMount(() => { + globalSDK.client.file + .status({ directory: props.directory }) + .then((x) => { + const files = x.data ?? [] + const dirty = files.length > 0 + setState({ status: "ready", dirty }) + void refresh() + }) + .catch(() => { + setState({ status: "error", dirty: false }) + }) + }) + + const handleReset = () => { + dialog.close() + void resetWorkspace(props.root, props.directory) + } + + const archivedCount = () => state.sessions.length + + const description = () => { + if (state.status === "loading") return language.t("workspace.status.checking") + if (state.status === "error") return language.t("workspace.status.error") + if (!state.dirty) return language.t("workspace.status.clean") + return language.t("workspace.status.dirty") + } + + const archivedLabel = () => { + const count = archivedCount() + if (count === 0) return language.t("workspace.reset.archived.none") + if (count === 1) return language.t("workspace.reset.archived.one") + return language.t("workspace.reset.archived.many", { count }) + } + + return ( + +
+
+ + {language.t("workspace.reset.confirm", { name: name() })} + + + {description()} {archivedLabel()} {language.t("workspace.reset.note")} + +
+
+ + +
+
+
+ ) + } + + const activeRoute = { + session: "", + sessionProject: "", + } + + createEffect( + on( + () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const, + ([ready, dir, id]) => { + if (!ready || !dir) { + activeRoute.session = "" + activeRoute.sessionProject = "" + return + } + + const directory = decode64(dir) + if (!directory) return + + const root = touchProjectRoute() ?? activeProjectRoot(directory) + + if (!id) { + activeRoute.session = "" + activeRoute.sessionProject = "" + return + } + + const session = `${dir}/${id}` + if (session !== activeRoute.session) { + activeRoute.session = session + activeRoute.sessionProject = syncSessionRoute(directory, id, root) + return + } + + if (root === activeRoute.sessionProject) return + activeRoute.sessionProject = rememberSessionRoute(directory, id, root) + }, + ), + ) + + createEffect(() => { + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 + document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) + }) + + const loadedSessionDirs = new Set() + + createEffect( + on( + visibleSessionDirs, + (dirs) => { + if (dirs.length === 0) { + loadedSessionDirs.clear() + return + } + + const next = new Set(dirs) + for (const directory of next) { + if (loadedSessionDirs.has(directory)) continue + globalSync.project.loadSessions(directory) + } + + loadedSessionDirs.clear() + for (const directory of next) { + loadedSessionDirs.add(directory) + } + }, + { defer: true }, + ), + ) + + function handleDragStart(event: unknown) { + const id = getDraggableId(event) + if (!id) return + setHoverProject(undefined) + setStore("activeProject", id) + } + + function handleDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (draggable && droppable) { + const projects = layout.projects.list() + const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) + const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) + if (fromIndex !== toIndex && toIndex !== -1) { + layout.projects.move(draggable.id.toString(), toIndex) + } + } + } + + function handleDragEnd() { + setStore("activeProject", undefined) + } + + function workspaceIds(project: LocalProject | undefined) { + if (!project) return [] + const local = project.worktree + const dirs = [local, ...(project.sandboxes ?? [])] + const active = currentProject() + const directory = active?.worktree === project.worktree ? currentDir() : undefined + const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined + const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false + + const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) + if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)] + if (!extra) return ordered + if (pending) return ordered + return [...ordered, extra] + } + + const sidebarProject = createMemo(() => { + if (layout.sidebar.opened()) return currentProject() + const hovered = hoverProjectData() + if (hovered) return hovered + return currentProject() + }) + + function handleWorkspaceDragStart(event: unknown) { + const id = getDraggableId(event) + if (!id) return + setStore("activeWorkspace", id) + } + + function handleWorkspaceDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const project = sidebarProject() + if (!project) return + + const ids = workspaceIds(project) + const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) + const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) + if (fromIndex === -1 || toIndex === -1) return + if (fromIndex === toIndex) return + + const result = ids.slice() + const [item] = result.splice(fromIndex, 1) + if (!item) return + result.splice(toIndex, 0, item) + setStore( + "workspaceOrder", + project.worktree, + result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)), + ) + } + + function handleWorkspaceDragEnd() { + setStore("activeWorkspace", undefined) + } + + const createWorkspace = async (project: LocalProject) => { + clearSidebarHoverState() + const created = await globalSDK.client.worktree + .create({ directory: project.worktree }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("workspace.create.failed.title"), + description: errorMessage(err, language.t("common.requestFailed")), + }) + return undefined + }) + + if (!created?.directory) return + + setWorkspaceName(created.directory, created.branch, project.id, created.branch) + + const local = project.worktree + const key = workspaceKey(created.directory) + const root = workspaceKey(local) + + setBusy(created.directory, true) + WorktreeState.pending(created.directory) + setStore("workspaceExpanded", key, true) + if (key !== created.directory) { + setStore("workspaceExpanded", created.directory, true) + } + setStore("workspaceOrder", project.worktree, (prev) => { + const existing = prev ?? [] + const next = existing.filter((item) => { + const id = workspaceKey(item) + return id !== root && id !== key + }) + return [created.directory, ...next] + }) + + globalSync.child(created.directory) + navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`) + } + + const workspaceSidebarCtx: WorkspaceSidebarContext = { + currentDir, + sidebarExpanded, + sidebarHovering, + nav: () => state.nav, + hoverSession: () => state.hoverSession, + setHoverSession, + clearHoverProjectSoon, + prefetchSession, + archiveSession, + workspaceName, + renameWorkspace, + editorOpen, + openEditor, + closeEditor, + setEditor, + InlineEditor, + isBusy, + workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local), + setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value), + showResetWorkspaceDialog: (root, directory) => + dialog.show(() => ), + showDeleteWorkspaceDialog: (root, directory) => + dialog.show(() => ), + setScrollContainerRef: (el, mobile) => { + if (!mobile) scrollContainerRef = el + }, + } + + const projectSidebarCtx: ProjectSidebarContext = { + currentDir, + sidebarOpened: () => layout.sidebar.opened(), + sidebarHovering, + hoverProject: () => state.hoverProject, + nav: () => state.nav, + onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), + onProjectMouseLeave: (worktree) => aim.leave(worktree), + onProjectFocus: (worktree) => aim.activate(worktree), + navigateToProject, + openSidebar: () => layout.sidebar.open(), + closeProject, + showEditProjectDialog, + toggleProjectWorkspaces, + workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(), + workspaceIds, + workspaceLabel, + sessionProps: { + sidebarExpanded, + sidebarHovering, + nav: () => state.nav, + hoverSession: () => state.hoverSession, + setHoverSession, + clearHoverProjectSoon, + prefetchSession, + archiveSession, + }, + setHoverSession, + } + + const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => { + const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) + const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) + const projectName = createMemo(() => { + const project = panelProps.project + if (!project) return "" + return project.name || getFilename(project.worktree) + }) + const projectId = createMemo(() => panelProps.project?.id ?? "") + const workspaces = createMemo(() => workspaceIds(panelProps.project)) + const unseenCount = createMemo(() => + workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), + ) + const clearNotifications = () => + workspaces() + .filter((directory) => notification.project.unseenCount(directory) > 0) + .forEach((directory) => notification.project.markViewed(directory)) + const workspacesEnabled = createMemo(() => { + const project = panelProps.project + if (!project) return false + if (project.vcs !== "git") return false + return layout.sidebar.workspaces(project.worktree)() + }) + const homedir = createMemo(() => globalSync.data.path.home) + + return ( +
+ + {(p) => ( + <> +
+
+
+ renameProject(p(), next)} + class="text-14-medium text-text-strong truncate" + displayClass="text-14-medium text-text-strong truncate" + stopPropagation + /> + + + + {p().worktree.replace(homedir(), "~")} + + +
+ + + + + + showEditProjectDialog(p())}> + {language.t("common.edit")} + + toggleProjectWorkspaces(p())} + > + + {layout.sidebar.workspaces(p().worktree)() + ? language.t("sidebar.workspaces.disable") + : language.t("sidebar.workspaces.enable")} + + + + + {language.t("sidebar.project.clearNotifications")} + + + + closeProject(p().worktree)} + > + {language.t("common.close")} + + + + +
+
+ +
+ +
+ +
+
+ +
+ + } + > + <> +
+ +
+
+ + + +
{ + if (!panelProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + + + {(directory) => ( + + )} + + +
+ + store.activeWorkspace} + workspaceLabel={workspaceLabel} + /> + +
+
+ +
+
+ + )} +
+ +
0 && providers.paid().length === 0), + }} + > +
+
+
+
{language.t("sidebar.gettingStarted.title")}
+
+ {language.t("sidebar.gettingStarted.line1")} +
+
+ {language.t("sidebar.gettingStarted.line2")} +
+
+
+ + +
+
+
+
+
+ ) + } + + return ( +
+ +
+
+
+ + + + +
+ ) +} diff --git a/Agent-backend/packages/app/src/pages/layout/CLAUDE.md b/Agent-backend/packages/app/src/pages/layout/CLAUDE.md new file mode 100644 index 000000000..dd2a009b4 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/CLAUDE.md @@ -0,0 +1,65 @@ +# layout + +> **Path:** `Agent-backend/packages/app/src/pages/layout/` + +## Purpose + +Sidebar components and layout helpers for the OpenCode web app's main shell. Contains the full sidebar system (project list, workspace grouping, session items, shell sessions), deep link handling, inline text editing, and shared helper utilities. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Sidebar architecture, session sorting, drag-and-drop patterns, and deep link routing directly inform Orbit's sidebar | +| Orbit CLI | `not used` | CLI TUI has its own sidebar in `packages/opencode/src/cli/cmd/tui/` | + +## Recommendation + +**KEEP AS REFERENCE** — Shows how to build a complete sidebar with project→workspace→session hierarchy, drag-and-drop reordering, and deep link routing. `helpers.ts` contains portable pure logic. The sidebar is one of the most complex UI pieces in any code editor — worth studying before building Orbit's. + +## When to Reference This + +| If you're building... | Read this file | What you'll learn | +| ----------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| **Session list in sidebar** with sorting and grouping | `sidebar-workspace.tsx` + `helpers.ts` | Session sorting (recent-first with 1min recency window), root session filtering, parent→child map for nested sessions | +| **Project list** with drag-and-drop cards | `sidebar-project.tsx` | Sortable project cards, hover cards showing session previews, context menus (archive, rename, workspaces toggle) | +| **Session list items** (title, time, status) | `sidebar-items.tsx` | `SessionItem` component with title truncation, relative timestamps, status indicators, `ProjectIcon` with 6-color avatar system | +| **Workspace ordering** and multi-workspace sidebar | `helpers.ts` → `effectiveWorkspaceOrder()` | How to merge persisted ordering with live workspaces, dedup by workspace key | +| **Shell/terminal sessions** grouped separately | `sidebar-shell.tsx` | Terminal-initiated sessions as a separate sidebar section | +| **Deep link handling** (`opencode://` URLs) | `deep-links.ts` | Protocol URL parsing and routing to correct project/session | +| **Inline rename** in sidebar | `inline-editor.tsx` | Inline text editing component with focus management, escape/enter handling | + +## Files + +| File | Size | Purpose | Portable? | Tests | +| ------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- | --------------------------------- | +| `sidebar-workspace.tsx` | 21KB | Workspace sidebar — session list grouped by workspace, expand/collapse, drag-and-drop reorder | SolidJS-specific | `sidebar-workspace.test.ts` | +| `sidebar-project.tsx` | 15KB | Project sidebar — sortable cards, hover cards, context menus | SolidJS-specific | — | +| `sidebar-items.tsx` | 15KB | Session list items — title, time, status indicator, `ProjectIcon` avatar | SolidJS-specific | — | +| `sidebar-shell.tsx` | 4.5KB | Shell sessions sidebar section | SolidJS-specific | `sidebar-shell.test.ts` | +| `helpers.ts` | 3.7KB | **Pure logic.** `sortSessions()`, `childMapByParent()`, `sortedRootSessions()`, `effectiveWorkspaceOrder()`, `displayName()`, `errorMessage()`, `getDraggableId()` | **Yes — no SolidJS** | `helpers.test.ts` | +| `deep-links.ts` | 1.5KB | `opencode://` protocol URL routing | Mostly portable | — | +| `inline-editor.tsx` | 3.4KB | Inline text editing for rename actions | SolidJS-specific | — | +| `sidebar-project-helpers.ts` | 0.4KB | `projectSelected()`, `projectTileActive()` selection state | **Yes — pure logic** | `sidebar-project-helpers.test.ts` | +| `sidebar-workspace-helpers.ts` | 0.1KB | Workspace-level helper | **Yes — pure logic** | — | +| `sidebar-shell-helpers.ts` | 0.1KB | Shell session helper | **Yes — pure logic** | — | + +## Key Data Patterns (portable to React) + +### Session sorting + +`helpers.ts` → `sortSessions(now)`: sorts sessions recent-first. Sessions updated within the last 60 seconds are treated as "recent" and sorted by ID (stable order). Older sessions sort by `time.updated` descending. This is the same sort Orbit's sidebar needs. + +### Parent→child session map + +`helpers.ts` → `childMapByParent(sessions)`: builds a `Map` for nested/forked sessions. Used to render session trees in the sidebar. + +### Workspace ordering + +`helpers.ts` → `effectiveWorkspaceOrder(local, dirs, persisted?)`: merges live workspace directories with persisted user ordering, deduplicates by workspace key, and always puts the local directory first. Handles Windows drive letters and trailing slash normalization. + +## Notes + +- **`sidebar-workspace.tsx` is 21KB** — the largest file, managing session list rendering with expand/collapse, drag-and-drop, context menus, and workspace-level actions. Consider decomposing when building Orbit's equivalent. +- **4 helper files are pure logic** — `helpers.ts`, `sidebar-project-helpers.ts`, `sidebar-workspace-helpers.ts`, `sidebar-shell-helpers.ts` have zero SolidJS imports. Directly usable in React. +- **Drag-and-drop** uses `@thisbeyond/solid-dnd` — Orbit would use `@dnd-kit` (React equivalent) but the drag data shapes and reorder logic are the same. diff --git a/Agent-backend/packages/app/src/pages/layout/deep-links.ts b/Agent-backend/packages/app/src/pages/layout/deep-links.ts new file mode 100644 index 000000000..5dca421f7 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/deep-links.ts @@ -0,0 +1,50 @@ +export const deepLinkEvent = "opencode:deep-link" + +const parseUrl = (input: string) => { + if (!input.startsWith("opencode://")) return + if (typeof URL.canParse === "function" && !URL.canParse(input)) return + try { + return new URL(input) + } catch { + return + } +} + +export const parseDeepLink = (input: string) => { + const url = parseUrl(input) + if (!url) return + if (url.hostname !== "open-project") return + const directory = url.searchParams.get("directory") + if (!directory) return + return directory +} + +export const parseNewSessionDeepLink = (input: string) => { + const url = parseUrl(input) + if (!url) return + if (url.hostname !== "new-session") return + const directory = url.searchParams.get("directory") + if (!directory) return + const prompt = url.searchParams.get("prompt") || undefined + if (!prompt) return { directory } + return { directory, prompt } +} + +export const collectOpenProjectDeepLinks = (urls: string[]) => + urls.map(parseDeepLink).filter((directory): directory is string => !!directory) + +export const collectNewSessionDeepLinks = (urls: string[]) => + urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link) + +type OpenCodeWindow = Window & { + __OPENCODE__?: { + deepLinks?: string[] + } +} + +export const drainPendingDeepLinks = (target: OpenCodeWindow) => { + const pending = target.__OPENCODE__?.deepLinks ?? [] + if (pending.length === 0) return [] + if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = [] + return pending +} diff --git a/Agent-backend/packages/app/src/pages/layout/helpers.test.ts b/Agent-backend/packages/app/src/pages/layout/helpers.test.ts new file mode 100644 index 000000000..d1569dbd9 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/helpers.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test" +import { + collectNewSessionDeepLinks, + collectOpenProjectDeepLinks, + drainPendingDeepLinks, + parseDeepLink, + parseNewSessionDeepLink, +} from "./deep-links" +import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers" +import { type Session } from "@opencode-ai/sdk/v2/client" +import { hasProjectPermissions, latestRootSession } from "./helpers" + +const session = (input: Partial & Pick) => + ({ + title: "", + version: "v2", + parentID: undefined, + messageCount: 0, + permissions: { session: {}, share: {} }, + time: { created: 0, updated: 0, archived: undefined }, + ...input, + }) as Session + +describe("layout deep links", () => { + test("parses open-project deep links", () => { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + }) + + test("ignores non-project deep links", () => { + expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined() + expect(parseDeepLink("https://example.com")).toBeUndefined() + }) + + test("ignores malformed deep links safely", () => { + expect(() => parseDeepLink("opencode://open-project/%E0%A4%A%")).not.toThrow() + expect(parseDeepLink("opencode://open-project/%E0%A4%A%")).toBeUndefined() + }) + + test("parses links when URL.canParse is unavailable", () => { + const original = Object.getOwnPropertyDescriptor(URL, "canParse") + Object.defineProperty(URL, "canParse", { configurable: true, value: undefined }) + try { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + } finally { + if (original) Object.defineProperty(URL, "canParse", original) + if (!original) Reflect.deleteProperty(URL, "canParse") + } + }) + + test("ignores open-project deep links without directory", () => { + expect(parseDeepLink("opencode://open-project")).toBeUndefined() + expect(parseDeepLink("opencode://open-project?directory=")).toBeUndefined() + }) + + test("collects only valid open-project directories", () => { + const result = collectOpenProjectDeepLinks([ + "opencode://open-project?directory=/a", + "opencode://other?directory=/b", + "opencode://open-project?directory=/c", + ]) + expect(result).toEqual(["/a", "/c"]) + }) + + test("parses new-session deep links with optional prompt", () => { + expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" }) + expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({ + directory: "/tmp/demo", + prompt: "hello world", + }) + }) + + test("ignores new-session deep links without directory", () => { + expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined() + expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined() + }) + + test("collects only valid new-session deep links", () => { + const result = collectNewSessionDeepLinks([ + "opencode://new-session?directory=/a", + "opencode://open-project?directory=/b", + "opencode://new-session?directory=/c&prompt=ship%20it", + ]) + expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }]) + }) + + test("drains global deep links once", () => { + const target = { + __OPENCODE__: { + deepLinks: ["opencode://open-project?directory=/a"], + }, + } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } } + + expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"]) + expect(drainPendingDeepLinks(target)).toEqual([]) + }) +}) + +describe("layout workspace helpers", () => { + test("normalizes trailing slash in workspace key", () => { + expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") + expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") + }) + + test("preserves posix and drive roots in workspace key", () => { + expect(workspaceKey("/")).toBe("/") + expect(workspaceKey("///")).toBe("/") + expect(workspaceKey("C:\\")).toBe("C:\\") + expect(workspaceKey("C:\\\\\\")).toBe("C:\\") + expect(workspaceKey("C:///")).toBe("C:/") + }) + + test("keeps local first while preserving known order", () => { + const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"]) + expect(result).toEqual(["/root", "/c", "/b"]) + }) + + test("finds the latest root session across workspaces", () => { + const result = latestRootSession( + [ + { + path: { directory: "/root" }, + session: [session({ id: "root", directory: "/root", time: { created: 1, updated: 1, archived: undefined } })], + }, + { + path: { directory: "/workspace" }, + session: [ + session({ + id: "workspace", + directory: "/workspace", + time: { created: 2, updated: 2, archived: undefined }, + }), + ], + }, + ], + 120_000, + ) + + expect(result?.id).toBe("workspace") + }) + + test("detects project permissions with a filter", () => { + const result = hasProjectPermissions( + { + root: [{ id: "perm-root" }, { id: "perm-hidden" }], + child: [{ id: "perm-child" }], + }, + (item) => item.id === "perm-child", + ) + + expect(result).toBe(true) + }) + + test("ignores project permissions filtered out", () => { + const result = hasProjectPermissions( + { + root: [{ id: "perm-root" }], + }, + () => false, + ) + + expect(result).toBe(false) + }) + + test("ignores archived and child sessions when finding latest root session", () => { + const result = latestRootSession( + [ + { + path: { directory: "/workspace" }, + session: [ + session({ + id: "archived", + directory: "/workspace", + time: { created: 10, updated: 10, archived: 10 }, + }), + session({ + id: "child", + directory: "/workspace", + parentID: "parent", + time: { created: 20, updated: 20, archived: undefined }, + }), + session({ + id: "root", + directory: "/workspace", + time: { created: 30, updated: 30, archived: undefined }, + }), + ], + }, + ], + 120_000, + ) + + expect(result?.id).toBe("root") + }) + + test("extracts draggable id safely", () => { + expect(getDraggableId({ draggable: { id: "x" } })).toBe("x") + expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined() + expect(getDraggableId(null)).toBeUndefined() + }) + + test("formats fallback project display name", () => { + expect(displayName({ worktree: "/tmp/app" })).toBe("app") + expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") + }) + + test("extracts api error message and fallback", () => { + expect(errorMessage({ data: { message: "boom" } }, "fallback")).toBe("boom") + expect(errorMessage(new Error("broken"), "fallback")).toBe("broken") + expect(errorMessage("unknown", "fallback")).toBe("fallback") + }) +}) diff --git a/Agent-backend/packages/app/src/pages/layout/helpers.ts b/Agent-backend/packages/app/src/pages/layout/helpers.ts new file mode 100644 index 000000000..42315e589 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/helpers.ts @@ -0,0 +1,102 @@ +import { getFilename } from "@opencode-ai/util/path" +import { type Session } from "@opencode-ai/sdk/v2/client" + +export const workspaceKey = (directory: string) => { + const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) + if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` + if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" + return directory.replace(/[\\/]+$/, "") +} + +export function sortSessions(now: number) { + const oneMinuteAgo = now - 60 * 1000 + return (a: Session, b: Session) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + } +} + +export const isRootVisibleSession = (session: Session, directory: string) => + workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + +export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => + store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) + +export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => + stores + .flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) + .sort(sortSessions(now))[0] + +export function hasProjectPermissions( + request: Record, + include: (item: T) => boolean = () => true, +) { + return Object.values(request).some((list) => list?.some(include)) +} + +export const childMapByParent = (sessions: Session[]) => { + const map = new Map() + for (const session of sessions) { + if (!session.parentID) continue + const existing = map.get(session.parentID) + if (existing) { + existing.push(session.id) + continue + } + map.set(session.parentID, [session.id]) + } + return map +} + +export function getDraggableId(event: unknown): string | undefined { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined +} + +export const displayName = (project: { name?: string; worktree: string }) => + project.name || getFilename(project.worktree) + +export const errorMessage = (err: unknown, fallback: string) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return fallback +} + +export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => { + const root = workspaceKey(local) + const live = new Map() + + for (const dir of dirs) { + const key = workspaceKey(dir) + if (key === root) continue + if (!live.has(key)) live.set(key, dir) + } + + if (!persisted?.length) return [local, ...live.values()] + + const result = [local] + for (const dir of persisted) { + const key = workspaceKey(dir) + if (key === root) continue + const match = live.get(key) + if (!match) continue + result.push(match) + live.delete(key) + } + + return [...result, ...live.values()] +} + +export const syncWorkspaceOrder = effectiveWorkspaceOrder diff --git a/Agent-backend/packages/app/src/pages/layout/inline-editor.tsx b/Agent-backend/packages/app/src/pages/layout/inline-editor.tsx new file mode 100644 index 000000000..4189e4a72 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/inline-editor.tsx @@ -0,0 +1,126 @@ +import { createStore } from "solid-js/store" +import { onCleanup, Show, type Accessor } from "solid-js" +import { InlineInput } from "@opencode-ai/ui/inline-input" + +export function createInlineEditorController() { + // This controller intentionally supports one active inline editor at a time. + const [editor, setEditor] = createStore({ + active: "" as string, + value: "", + }) + + const editorOpen = (id: string) => editor.active === id + const editorValue = () => editor.value + const openEditor = (id: string, value: string) => { + if (!id) return + setEditor({ active: id, value }) + } + const closeEditor = () => setEditor({ active: "", value: "" }) + + const saveEditor = (callback: (next: string) => void) => { + const next = editor.value.trim() + if (!next) { + closeEditor() + return + } + closeEditor() + callback(next) + } + + const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => { + if (event.key === "Enter") { + event.preventDefault() + saveEditor(callback) + return + } + if (event.key !== "Escape") return + event.preventDefault() + closeEditor() + } + + const InlineEditor = (props: { + id: string + value: Accessor + onSave: (next: string) => void + class?: string + displayClass?: string + editing?: boolean + stopPropagation?: boolean + openOnDblClick?: boolean + }) => { + let frame: number | undefined + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + const isEditing = () => props.editing ?? editorOpen(props.id) + const stopEvents = () => props.stopPropagation ?? false + const allowDblClick = () => props.openOnDblClick ?? true + const stopPropagation = (event: Event) => { + if (!stopEvents()) return + event.stopPropagation() + } + const handleDblClick = (event: MouseEvent) => { + if (!allowDblClick()) return + stopPropagation(event) + openEditor(props.id, props.value()) + } + + return ( + + {props.value()} + + } + > + { + if (frame !== undefined) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + frame = undefined + if (!el.isConnected) return + el.focus() + }) + }} + value={editorValue()} + class={props.class} + onInput={(event) => setEditor("value", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + editorKeyDown(event, props.onSave) + }} + onBlur={closeEditor} + onPointerDown={stopPropagation} + onClick={stopPropagation} + onDblClick={stopPropagation} + onMouseDown={stopPropagation} + onMouseUp={stopPropagation} + onTouchStart={stopPropagation} + /> + + ) + } + + return { + editor, + editorOpen, + editorValue, + openEditor, + closeEditor, + saveEditor, + editorKeyDown, + setEditor, + InlineEditor, + } +} diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-items.tsx b/Agent-backend/packages/app/src/pages/layout/sidebar-items.tsx new file mode 100644 index 000000000..8dc03755e --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-items.tsx @@ -0,0 +1,399 @@ +import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client" +import { Avatar } from "@opencode-ai/ui/avatar" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { MessageNav } from "@opencode-ai/ui/message-nav" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { base64Encode } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" +import { A, useNavigate, useParams } from "@solidjs/router" +import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" +import { useNotification } from "@/context/notification" +import { usePermission } from "@/context/permission" +import { agentColor } from "@/utils/agent" +import { sessionPermissionRequest } from "../session/composer/session-request-tree" +import { hasProjectPermissions } from "./helpers" + +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + +export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { + const globalSync = useGlobalSync() + const notification = useNotification() + const permission = usePermission() + const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])]) + const unseenCount = createMemo(() => + dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), + ) + const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory))) + const hasPermissions = createMemo(() => + dirs().some((directory) => { + const [store] = globalSync.child(directory, { bootstrap: false }) + return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory)) + }), + ) + const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) + return ( +
+
+ +
+ +
+ +
+ ) +} + +export type SessionItemProps = { + session: Session + slug: string + mobile?: boolean + dense?: boolean + popover?: boolean + children: Map + sidebarExpanded: Accessor + sidebarHovering: Accessor + nav: Accessor + hoverSession: Accessor + setHoverSession: (id: string | undefined) => void + clearHoverProjectSoon: () => void + prefetchSession: (session: Session, priority?: "high" | "low") => void + archiveSession: (session: Session) => Promise +} + +const SessionRow = (props: { + session: Session + slug: string + mobile?: boolean + dense?: boolean + tint: Accessor + isWorking: Accessor + hasPermissions: Accessor + hasError: Accessor + unseenCount: Accessor + setHoverSession: (id: string | undefined) => void + clearHoverProjectSoon: () => void + sidebarOpened: Accessor + prefetchSession: (session: Session, priority?: "high" | "low") => void + scheduleHoverPrefetch: () => void + cancelHoverPrefetch: () => void +}): JSX.Element => ( + props.prefetchSession(props.session, "high")} + onClick={() => { + props.setHoverSession(undefined) + if (props.sidebarOpened()) return + props.clearHoverProjectSoon() + }} + > +
+
+ }> + + + + +
+ + +
+ + 0}> +
+ + +
+ + {props.session.title} + +
+
+) + +const SessionHoverPreview = (props: { + mobile?: boolean + nav: Accessor + hoverSession: Accessor + session: Session + sidebarHovering: Accessor + hoverReady: Accessor + hoverMessages: Accessor + language: ReturnType + isActive: Accessor + slug: string + setHoverSession: (id: string | undefined) => void + messageLabel: (message: Message) => string | undefined + onMessageSelect: (message: Message) => void + trigger: JSX.Element +}): JSX.Element => ( + props.setHoverSession(open ? props.session.id : undefined)} + > + {props.language.t("session.messages.loading")}
} + > +
+ +
+ + +) + +export const SessionItem = (props: SessionItemProps): JSX.Element => { + const params = useParams() + const navigate = useNavigate() + const layout = useLayout() + const language = useLanguage() + const notification = useNotification() + const permission = usePermission() + const globalSync = useGlobalSync() + const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) + const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) + const [sessionStore] = globalSync.child(props.session.directory) + const hasPermissions = createMemo(() => { + return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => { + return !permission.autoResponds(item, props.session.directory) + }) + }) + const isWorking = createMemo(() => { + if (hasPermissions()) return false + const status = sessionStore.session_status[props.session.id] + return status?.type === "busy" || status?.type === "retry" + }) + + const tint = createMemo(() => { + const messages = sessionStore.message[props.session.id] + if (!messages) return undefined + let user: Message | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "user") continue + user = message + break + } + if (!user?.agent) return undefined + + const agent = sessionStore.agent.find((a) => a.name === user.agent) + return agentColor(user.agent, agent?.color) + }) + + const hoverMessages = createMemo(() => + sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"), + ) + const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) + const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) + const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) + const isActive = createMemo(() => props.session.id === params.id) + + const hoverPrefetch = { + current: undefined as ReturnType | undefined, + } + const cancelHoverPrefetch = () => { + if (hoverPrefetch.current === undefined) return + clearTimeout(hoverPrefetch.current) + hoverPrefetch.current = undefined + } + const scheduleHoverPrefetch = () => { + if (hoverPrefetch.current !== undefined) return + hoverPrefetch.current = setTimeout(() => { + hoverPrefetch.current = undefined + props.prefetchSession(props.session) + }, 200) + } + + onCleanup(cancelHoverPrefetch) + + const messageLabel = (message: Message) => { + const parts = sessionStore.part[message.id] ?? [] + const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) + return text?.text + } + const item = ( + + ) + + return ( +
+ + {item} + + } + > + { + if (!isActive()) + layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) + + navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) + }} + trigger={item} + /> + + +
+ + { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> + +
+
+ ) +} + +export const NewSessionItem = (props: { + slug: string + mobile?: boolean + dense?: boolean + sidebarExpanded: Accessor + clearHoverProjectSoon: () => void + setHoverSession: (id: string | undefined) => void +}): JSX.Element => { + const layout = useLayout() + const language = useLanguage() + const label = language.t("command.session.new") + const tooltip = () => props.mobile || !props.sidebarExpanded() + const item = ( + { + props.setHoverSession(undefined) + if (layout.sidebar.opened()) return + props.clearHoverProjectSoon() + }} + > +
+
+ +
+ + {label} + +
+
+ ) + + return ( +
+ + {item} + + } + > + {item} + +
+ ) +} + +export const SessionSkeleton = (props: { count?: number }): JSX.Element => { + const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) + return ( +
+ + {() =>
} + +
+ ) +} diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-project-helpers.test.ts b/Agent-backend/packages/app/src/pages/layout/sidebar-project-helpers.test.ts new file mode 100644 index 000000000..75958d49e --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-project-helpers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { projectSelected, projectTileActive } from "./sidebar-project-helpers" + +describe("projectSelected", () => { + test("matches direct worktree", () => { + expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true) + }) + + test("matches sandbox worktree", () => { + expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true) + expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false) + }) +}) + +describe("projectTileActive", () => { + test("menu state always wins", () => { + expect( + projectTileActive({ + menu: true, + preview: false, + open: false, + overlay: false, + worktree: "/tmp/root", + }), + ).toBe(true) + }) + + test("preview mode uses open state", () => { + expect( + projectTileActive({ + menu: false, + preview: true, + open: true, + overlay: true, + hoverProject: "/tmp/other", + worktree: "/tmp/root", + }), + ).toBe(true) + }) + + test("overlay mode uses hovered project", () => { + expect( + projectTileActive({ + menu: false, + preview: false, + open: false, + overlay: true, + hoverProject: "/tmp/root", + worktree: "/tmp/root", + }), + ).toBe(true) + expect( + projectTileActive({ + menu: false, + preview: false, + open: false, + overlay: true, + hoverProject: "/tmp/other", + worktree: "/tmp/root", + }), + ).toBe(false) + }) +}) diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-project-helpers.ts b/Agent-backend/packages/app/src/pages/layout/sidebar-project-helpers.ts new file mode 100644 index 000000000..06d38a3cd --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-project-helpers.ts @@ -0,0 +1,11 @@ +export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) => + worktree === currentDir || sandboxes?.includes(currentDir) === true + +export const projectTileActive = (args: { + menu: boolean + preview: boolean + open: boolean + overlay: boolean + hoverProject?: string + worktree: string +}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree) diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-project.tsx b/Agent-backend/packages/app/src/pages/layout/sidebar-project.tsx new file mode 100644 index 000000000..187cd2f33 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-project.tsx @@ -0,0 +1,399 @@ +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { base64Encode } from "@opencode-ai/util/encode" +import { Button } from "@opencode-ai/ui/button" +import { ContextMenu } from "@opencode-ai/ui/context-menu" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { Icon } from "@opencode-ai/ui/icon" +import { createSortable } from "@thisbeyond/solid-dnd" +import { useLayout, type LocalProject } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useNotification } from "@/context/notification" +import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" +import { childMapByParent, displayName, sortedRootSessions } from "./helpers" +import { projectSelected, projectTileActive } from "./sidebar-project-helpers" + +export type ProjectSidebarContext = { + currentDir: Accessor + sidebarOpened: Accessor + sidebarHovering: Accessor + hoverProject: Accessor + nav: Accessor + onProjectMouseEnter: (worktree: string, event: MouseEvent) => void + onProjectMouseLeave: (worktree: string) => void + onProjectFocus: (worktree: string) => void + navigateToProject: (directory: string) => void + openSidebar: () => void + closeProject: (directory: string) => void + showEditProjectDialog: (project: LocalProject) => void + toggleProjectWorkspaces: (project: LocalProject) => void + workspacesEnabled: (project: LocalProject) => boolean + workspaceIds: (project: LocalProject) => string[] + workspaceLabel: (directory: string, branch?: string, projectId?: string) => string + sessionProps: Omit + setHoverSession: (id: string | undefined) => void +} + +export const ProjectDragOverlay = (props: { + projects: Accessor + activeProject: Accessor +}): JSX.Element => { + const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject())) + return ( + + {(p) => ( +
+ +
+ )} +
+ ) +} + +const ProjectTile = (props: { + project: LocalProject + mobile?: boolean + nav: Accessor + sidebarHovering: Accessor + selected: Accessor + active: Accessor + overlay: Accessor + suppressHover: Accessor + dirs: Accessor + onProjectMouseEnter: (worktree: string, event: MouseEvent) => void + onProjectMouseLeave: (worktree: string) => void + onProjectFocus: (worktree: string) => void + navigateToProject: (directory: string) => void + showEditProjectDialog: (project: LocalProject) => void + toggleProjectWorkspaces: (project: LocalProject) => void + workspacesEnabled: (project: LocalProject) => boolean + closeProject: (directory: string) => void + setMenu: (value: boolean) => void + setOpen: (value: boolean) => void + setSuppressHover: (value: boolean) => void + language: ReturnType +}): JSX.Element => { + const notification = useNotification() + const layout = useLayout() + const unseenCount = createMemo(() => + props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), + ) + + const clear = () => + props + .dirs() + .filter((directory) => notification.project.unseenCount(directory) > 0) + .forEach((directory) => notification.project.markViewed(directory)) + + return ( + { + props.setMenu(value) + props.setSuppressHover(value) + if (value) props.setOpen(false) + }} + > + { + if (!props.overlay()) return + if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return + props.setSuppressHover(true) + event.preventDefault() + }} + onMouseEnter={(event: MouseEvent) => { + if (!props.overlay()) return + if (props.suppressHover()) return + props.onProjectMouseEnter(props.project.worktree, event) + }} + onMouseLeave={() => { + if (props.suppressHover()) props.setSuppressHover(false) + if (!props.overlay()) return + props.onProjectMouseLeave(props.project.worktree) + }} + onFocus={() => { + if (!props.overlay()) return + if (props.suppressHover()) return + props.onProjectFocus(props.project.worktree) + }} + onClick={() => { + if (props.selected()) { + props.setSuppressHover(true) + layout.sidebar.toggle() + return + } + props.setSuppressHover(false) + props.navigateToProject(props.project.worktree) + }} + onBlur={() => props.setOpen(false)} + > + + + + + props.showEditProjectDialog(props.project)}> + {props.language.t("common.edit")} + + props.toggleProjectWorkspaces(props.project)} + > + + {props.workspacesEnabled(props.project) + ? props.language.t("sidebar.workspaces.disable") + : props.language.t("sidebar.workspaces.enable")} + + + + {props.language.t("sidebar.project.clearNotifications")} + + + props.closeProject(props.project.worktree)} + > + {props.language.t("common.close")} + + + + + ) +} + +const ProjectPreviewPanel = (props: { + project: LocalProject + mobile?: boolean + selected: Accessor + workspaceEnabled: Accessor + workspaces: Accessor + label: (directory: string) => string + projectSessions: Accessor> + projectChildren: Accessor> + workspaceSessions: (directory: string) => ReturnType + workspaceChildren: (directory: string) => Map + setOpen: (value: boolean) => void + ctx: ProjectSidebarContext + language: ReturnType +}): JSX.Element => ( +
+
+
{displayName(props.project)}
+
+
{props.language.t("sidebar.project.recentSessions")}
+
+ + {(session) => ( + + )} + + } + > + + {(directory) => { + const sessions = createMemo(() => props.workspaceSessions(directory)) + const children = createMemo(() => props.workspaceChildren(directory)) + return ( +
+
+
+ +
+ {props.label(directory)} +
+ + {(session) => ( + + )} + +
+ ) + }} +
+
+
+
+ +
+
+) + +export const SortableProject = (props: { + project: LocalProject + mobile?: boolean + ctx: ProjectSidebarContext + sortNow: Accessor +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const sortable = createSortable(props.project.worktree) + const selected = createMemo(() => + projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes), + ) + const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) + const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) + const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) + const [state, setState] = createStore({ + open: false, + menu: false, + suppressHover: false, + }) + + const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) + const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) + const active = createMemo(() => + projectTileActive({ + menu: state.menu, + preview: preview(), + open: state.open, + overlay: overlay(), + hoverProject: props.ctx.hoverProject(), + worktree: props.project.worktree, + }), + ) + + createEffect(() => { + if (preview()) return + if (!state.open) return + setState("open", false) + }) + + createEffect(() => { + if (!selected()) return + if (!state.open) return + setState("open", false) + }) + + const label = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id) + return `${kind} : ${name}` + } + + const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2)) + const projectChildren = createMemo(() => childMapByParent(projectStore().session)) + const workspaceSessions = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return sortedRootSessions(data, props.sortNow()).slice(0, 2) + } + const workspaceChildren = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return childMapByParent(data.session) + } + const tile = () => ( + state.suppressHover} + dirs={dirs} + onProjectMouseEnter={props.ctx.onProjectMouseEnter} + onProjectMouseLeave={props.ctx.onProjectMouseLeave} + onProjectFocus={props.ctx.onProjectFocus} + navigateToProject={props.ctx.navigateToProject} + showEditProjectDialog={props.ctx.showEditProjectDialog} + toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces} + workspacesEnabled={props.ctx.workspacesEnabled} + closeProject={props.ctx.closeProject} + setMenu={(value) => setState("menu", value)} + setOpen={(value) => setState("open", value)} + setSuppressHover={(value) => setState("suppressHover", value)} + language={language} + /> + ) + + return ( + // @ts-ignore +
+ + { + if (state.menu) return + if (value && state.suppressHover) return + setState("open", value) + if (value) props.ctx.setHoverSession(undefined) + }} + > + setState("open", value)} + ctx={props.ctx} + language={language} + /> + + +
+ ) +} diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-shell-helpers.ts b/Agent-backend/packages/app/src/pages/layout/sidebar-shell-helpers.ts new file mode 100644 index 000000000..93c286c15 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-shell-helpers.ts @@ -0,0 +1 @@ +export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-shell.test.ts b/Agent-backend/packages/app/src/pages/layout/sidebar-shell.test.ts new file mode 100644 index 000000000..694025a65 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-shell.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { sidebarExpanded } from "./sidebar-shell-helpers" + +describe("sidebarExpanded", () => { + test("expands on mobile regardless of desktop open state", () => { + expect(sidebarExpanded(true, false)).toBe(true) + }) + + test("follows desktop open state when not mobile", () => { + expect(sidebarExpanded(false, true)).toBe(true) + expect(sidebarExpanded(false, false)).toBe(false) + }) +}) diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-shell.tsx b/Agent-backend/packages/app/src/pages/layout/sidebar-shell.tsx new file mode 100644 index 000000000..d3070e374 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-shell.tsx @@ -0,0 +1,126 @@ +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" +import { ConstrainDragXAxis } from "@/utils/solid-dnd" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { type LocalProject } from "@/context/layout" +import { sidebarExpanded } from "./sidebar-shell-helpers" + +export const SidebarContent = (props: { + mobile?: boolean + opened: Accessor + aimMove: (event: MouseEvent) => void + projects: Accessor + renderProject: (project: LocalProject) => JSX.Element + handleDragStart: (event: unknown) => void + handleDragEnd: () => void + handleDragOver: (event: DragEvent) => void + openProjectLabel: JSX.Element + openProjectKeybind: Accessor + onOpenProject: () => void + renderProjectOverlay: () => JSX.Element + settingsLabel: Accessor + settingsKeybind: Accessor + onOpenSettings: () => void + helpLabel: Accessor + onOpenHelp: () => void + renderPanel: () => JSX.Element +}): JSX.Element => { + const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) + const placement = () => (props.mobile ? "bottom" : "right") + let panel: HTMLDivElement | undefined + + createEffect(() => { + const el = panel + if (!el) return + if (expanded()) { + el.removeAttribute("inert") + return + } + el.setAttribute("inert", "") + }) + + return ( +
+
+
+ + + +
+ p.worktree)}> + {(project) => props.renderProject(project)} + + + {props.openProjectLabel} + + {props.openProjectKeybind()} + +
+ } + > + + +
+ {props.renderProjectOverlay()} + +
+
+ + + + + + +
+
+ +
{ + panel = el + }} + classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }} + aria-hidden={!expanded()} + > + {props.renderPanel()} +
+
+ ) +} diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-workspace-helpers.ts b/Agent-backend/packages/app/src/pages/layout/sidebar-workspace-helpers.ts new file mode 100644 index 000000000..aa7cb480e --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-workspace-helpers.ts @@ -0,0 +1,2 @@ +export const workspaceOpenState = (expanded: Record, directory: string, local: boolean) => + expanded[directory] ?? local diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-workspace.test.ts b/Agent-backend/packages/app/src/pages/layout/sidebar-workspace.test.ts new file mode 100644 index 000000000..d71c39fc8 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-workspace.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { workspaceOpenState } from "./sidebar-workspace-helpers" + +describe("workspaceOpenState", () => { + test("defaults to local workspace open", () => { + expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true) + }) + + test("uses persisted expansion state when present", () => { + expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false) + expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true) + }) +}) diff --git a/Agent-backend/packages/app/src/pages/layout/sidebar-workspace.tsx b/Agent-backend/packages/app/src/pages/layout/sidebar-workspace.tsx new file mode 100644 index 000000000..c317b9c5e --- /dev/null +++ b/Agent-backend/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -0,0 +1,533 @@ +import { useNavigate, useParams } from "@solidjs/router" +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { createSortable } from "@thisbeyond/solid-dnd" +import { createMediaQuery } from "@solid-primitives/media" +import { base64Encode } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" +import { Button } from "@opencode-ai/ui/button" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { type Session } from "@opencode-ai/sdk/v2/client" +import { type LocalProject } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" +import { childMapByParent, sortedRootSessions } from "./helpers" + +type InlineEditorComponent = (props: { + id: string + value: Accessor + onSave: (next: string) => void + class?: string + displayClass?: string + editing?: boolean + stopPropagation?: boolean + openOnDblClick?: boolean +}) => JSX.Element + +export type WorkspaceSidebarContext = { + currentDir: Accessor + sidebarExpanded: Accessor + sidebarHovering: Accessor + nav: Accessor + hoverSession: Accessor + setHoverSession: (id: string | undefined) => void + clearHoverProjectSoon: () => void + prefetchSession: (session: Session, priority?: "high" | "low") => void + archiveSession: (session: Session) => Promise + workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined + renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void + editorOpen: (id: string) => boolean + openEditor: (id: string, value: string) => void + closeEditor: () => void + setEditor: (key: "value", value: string) => void + InlineEditor: InlineEditorComponent + isBusy: (directory: string) => boolean + workspaceExpanded: (directory: string, local: boolean) => boolean + setWorkspaceExpanded: (directory: string, value: boolean) => void + showResetWorkspaceDialog: (root: string, directory: string) => void + showDeleteWorkspaceDialog: (root: string, directory: string) => void + setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void +} + +export const WorkspaceDragOverlay = (props: { + sidebarProject: Accessor + activeWorkspace: Accessor + workspaceLabel: (directory: string, branch?: string, projectId?: string) => string +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const label = createMemo(() => { + const project = props.sidebarProject() + if (!project) return + const directory = props.activeWorkspace() + if (!directory) return + + const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + return `${kind} : ${name}` + }) + + return ( + + {(value) =>
{value()}
} +
+ ) +} + +const WorkspaceHeader = (props: { + local: Accessor + busy: Accessor + open: Accessor + directory: string + language: ReturnType + branch: Accessor + workspaceValue: Accessor + workspaceEditActive: Accessor + InlineEditor: WorkspaceSidebarContext["InlineEditor"] + renameWorkspace: WorkspaceSidebarContext["renameWorkspace"] + setEditor: WorkspaceSidebarContext["setEditor"] + projectId?: string +}): JSX.Element => ( +
+
+ }> + + +
+ + {props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} : + + + {props.branch() ?? getFilename(props.directory)} + + } + > + { + const trimmed = next.trim() + if (!trimmed) return + props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch()) + props.setEditor("value", props.workspaceValue()) + }} + class="text-14-medium text-text-base min-w-0 truncate" + displayClass="text-14-medium text-text-base min-w-0 truncate" + editing={props.workspaceEditActive()} + stopPropagation={false} + openOnDblClick={false} + /> + +
+ +
+
+) + +const WorkspaceActions = (props: { + directory: string + local: Accessor + busy: Accessor + menuOpen: Accessor + pendingRename: Accessor + setMenuOpen: (open: boolean) => void + setPendingRename: (value: boolean) => void + sidebarHovering: Accessor + mobile?: boolean + nav: Accessor + touch: Accessor + language: ReturnType + workspaceValue: Accessor + openEditor: WorkspaceSidebarContext["openEditor"] + showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] + showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] + root: string + setHoverSession: WorkspaceSidebarContext["setHoverSession"] + clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] + navigateToNewSession: () => void +}): JSX.Element => ( +
+ props.setMenuOpen(open)} + > + + + + + { + if (!props.pendingRename()) return + event.preventDefault() + props.setPendingRename(false) + props.openEditor(`workspace:${props.directory}`, props.workspaceValue()) + }} + > + { + props.setPendingRename(true) + props.setMenuOpen(false) + }} + > + {props.language.t("common.rename")} + + props.showResetWorkspaceDialog(props.root, props.directory)} + > + {props.language.t("common.reset")} + + props.showDeleteWorkspaceDialog(props.root, props.directory)} + > + {props.language.t("common.delete")} + + + + + + + { + event.preventDefault() + event.stopPropagation() + props.setHoverSession(undefined) + props.clearHoverProjectSoon() + props.navigateToNewSession() + }} + /> + + +
+) + +const WorkspaceSessionList = (props: { + slug: Accessor + mobile?: boolean + ctx: WorkspaceSidebarContext + showNew: Accessor + loading: Accessor + sessions: Accessor + children: Accessor> + hasMore: Accessor + loadMore: () => Promise + language: ReturnType +}): JSX.Element => ( + +) + +export const SortableWorkspace = (props: { + ctx: WorkspaceSidebarContext + directory: string + project: LocalProject + sortNow: Accessor + mobile?: boolean +}): JSX.Element => { + const navigate = useNavigate() + const params = useParams() + const globalSync = useGlobalSync() + const language = useLanguage() + const sortable = createSortable(props.directory) + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) + const [menu, setMenu] = createStore({ + open: false, + pendingRename: false, + }) + const slug = createMemo(() => base64Encode(props.directory)) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) + const children = createMemo(() => childMapByParent(workspaceStore.session)) + const local = createMemo(() => props.directory === props.project.worktree) + const active = createMemo(() => props.ctx.currentDir() === props.directory) + const workspaceValue = createMemo(() => { + const branch = workspaceStore.vcs?.branch + const name = branch ?? getFilename(props.directory) + return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name + }) + const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) + const boot = createMemo(() => open() || active()) + const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) + const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) + const busy = createMemo(() => props.ctx.isBusy(props.directory)) + const wasBusy = createMemo((prev) => prev || busy(), false) + const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) + const touch = createMediaQuery("(hover: none)") + const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id))) + const loadMore = async () => { + setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5) + await globalSync.project.loadSessions(props.directory) + } + + const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`)) + + const openWrapper = (value: boolean) => { + props.ctx.setWorkspaceExpanded(props.directory, value) + if (value) return + if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor() + } + + createEffect(() => { + if (!boot()) return + globalSync.child(props.directory, { bootstrap: true }) + }) + + return ( +
+ +
+
+
+ + workspaceStore.vcs?.branch} + workspaceValue={workspaceValue} + workspaceEditActive={workspaceEditActive} + InlineEditor={props.ctx.InlineEditor} + renameWorkspace={props.ctx.renameWorkspace} + setEditor={props.ctx.setEditor} + projectId={props.project.id} + /> + + } + > +
+ workspaceStore.vcs?.branch} + workspaceValue={workspaceValue} + workspaceEditActive={workspaceEditActive} + InlineEditor={props.ctx.InlineEditor} + renameWorkspace={props.ctx.renameWorkspace} + setEditor={props.ctx.setEditor} + projectId={props.project.id} + /> +
+
+ menu.open} + pendingRename={() => menu.pendingRename} + setMenuOpen={(open) => setMenu("open", open)} + setPendingRename={(value) => setMenu("pendingRename", value)} + sidebarHovering={props.ctx.sidebarHovering} + mobile={props.mobile} + nav={props.ctx.nav} + touch={touch} + language={language} + workspaceValue={workspaceValue} + openEditor={props.ctx.openEditor} + showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} + showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} + root={props.project.worktree} + setHoverSession={props.ctx.setHoverSession} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + navigateToNewSession={() => navigate(`/${slug()}/session`)} + /> +
+
+
+ + + + +
+
+ ) +} + +export const LocalWorkspace = (props: { + ctx: WorkspaceSidebarContext + project: LocalProject + sortNow: Accessor + mobile?: boolean +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const workspace = createMemo(() => { + const [store, setStore] = globalSync.child(props.project.worktree) + return { store, setStore } + }) + const slug = createMemo(() => base64Encode(props.project.worktree)) + const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) + const children = createMemo(() => childMapByParent(workspace().store.session)) + const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) + const loading = createMemo(() => !booted() && sessions().length === 0) + const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) + const loadMore = async () => { + workspace().setStore("limit", (limit) => (limit ?? 0) + 5) + await globalSync.project.loadSessions(props.project.worktree) + } + + return ( +
props.ctx.setScrollContainerRef(el, props.mobile)} + class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + +
+ ) +} diff --git a/Agent-backend/packages/app/src/pages/session.tsx b/Agent-backend/packages/app/src/pages/session.tsx new file mode 100644 index 000000000..c1552ad02 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session.tsx @@ -0,0 +1,1407 @@ +import type { Project, UserMessage } from "@opencode-ai/sdk/v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { + onCleanup, + Show, + Match, + Switch, + createMemo, + createEffect, + createComputed, + on, + onMount, + untrack, +} from "solid-js" +import { createMediaQuery } from "@solid-primitives/media" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { useLocal } from "@/context/local" +import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" +import { createStore } from "solid-js/store" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Select } from "@opencode-ai/ui/select" +import { createAutoScroll } from "@opencode-ai/ui/hooks" +import { Button } from "@opencode-ai/ui/button" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode, checksum } from "@opencode-ai/util/encode" +import { useNavigate, useParams, useSearchParams } from "@solidjs/router" +import { NewSessionView, SessionHeader } from "@/components/session" +import { useComments } from "@/context/comments" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" +import { createOpenReviewFile, createSizing } from "@/pages/session/helpers" +import { MessageTimeline } from "@/pages/session/message-timeline" +import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" +import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers" +import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" +import { SessionSidePanel } from "@/pages/session/session-side-panel" +import { TerminalPanel } from "@/pages/session/terminal-panel" +import { useSessionCommands } from "@/pages/session/use-session-commands" +import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" +import { same } from "@/utils/same" +import { formatServerError } from "@/utils/server-errors" + +const emptyUserMessages: UserMessage[] = [] + +type SessionHistoryWindowInput = { + sessionID: () => string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise + userScrolled: () => boolean + scroller: () => HTMLDivElement | undefined +} + +/** + * Maintains the rendered history window for a session timeline. + * + * It keeps initial paint bounded to recent turns, reveals cached turns in + * small batches while scrolling upward, and prefetches older history near top. + */ +function createSessionHistoryWindow(input: SessionHistoryWindowInput) { + const turnInit = 10 + const turnBatch = 8 + const turnScrollThreshold = 200 + const turnPrefetchBuffer = 16 + const prefetchCooldownMs = 400 + const prefetchNoGrowthLimit = 2 + + const [state, setState] = createStore({ + turnID: undefined as string | undefined, + turnStart: 0, + prefetchUntil: 0, + prefetchNoGrowth: 0, + }) + + const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) + + const turnStart = createMemo(() => { + const id = input.sessionID() + const len = input.visibleUserMessages().length + if (!id || len <= 0) return 0 + if (state.turnID !== id) return initialTurnStart(len) + if (state.turnStart <= 0) return 0 + if (state.turnStart >= len) return initialTurnStart(len) + return state.turnStart + }) + + const setTurnStart = (start: number) => { + const id = input.sessionID() + const next = start > 0 ? start : 0 + if (!id) { + setState({ turnID: undefined, turnStart: next }) + return + } + setState({ turnID: id, turnStart: next }) + } + + const renderedUserMessages = createMemo( + () => { + const msgs = input.visibleUserMessages() + const start = turnStart() + if (start <= 0) return msgs + return msgs.slice(start) + }, + emptyUserMessages, + { + equals: same, + }, + ) + + const preserveScroll = (fn: () => void) => { + const el = input.scroller() + if (!el) { + fn() + return + } + const beforeTop = el.scrollTop + const beforeHeight = el.scrollHeight + fn() + requestAnimationFrame(() => { + const delta = el.scrollHeight - beforeHeight + if (!delta) return + el.scrollTop = beforeTop + delta + }) + } + + const backfillTurns = () => { + const start = turnStart() + if (start <= 0) return + + const next = start - turnBatch + const nextStart = next > 0 ? next : 0 + + preserveScroll(() => setTurnStart(nextStart)) + } + + /** Button path: reveal all cached turns, fetch older history, reveal one batch. */ + const loadAndReveal = async () => { + const id = input.sessionID() + if (!id) return + + const start = turnStart() + const beforeVisible = input.visibleUserMessages().length + + if (start > 0) setTurnStart(0) + + if (!input.historyMore() || input.historyLoading()) return + + await input.loadMore(id) + if (input.sessionID() !== id) return + + const afterVisible = input.visibleUserMessages().length + const growth = afterVisible - beforeVisible + if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0) + if (growth <= 0) return + if (turnStart() !== 0) return + + const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch) + const nextStart = Math.max(0, afterVisible - target) + preserveScroll(() => setTurnStart(nextStart)) + } + + /** Scroll/prefetch path: fetch older history from server. */ + const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { + const id = input.sessionID() + if (!id) return + if (!input.historyMore() || input.historyLoading()) return + + if (opts?.prefetch) { + const now = Date.now() + if (state.prefetchUntil > now) return + if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return + setState("prefetchUntil", now + prefetchCooldownMs) + } + + const start = turnStart() + const beforeVisible = input.visibleUserMessages().length + const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length + + await input.loadMore(id) + if (input.sessionID() !== id) return + + const afterVisible = input.visibleUserMessages().length + const growth = afterVisible - beforeVisible + + if (opts?.prefetch) { + setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1) + } else if (growth > 0 && state.prefetchNoGrowth) { + setState("prefetchNoGrowth", 0) + } + + if (growth <= 0) return + if (turnStart() !== start) return + + const reveal = !opts?.prefetch + const currentRendered = renderedUserMessages().length + const base = Math.max(beforeRendered, currentRendered) + const target = reveal ? Math.min(afterVisible, base + turnBatch) : base + const nextStart = Math.max(0, afterVisible - target) + preserveScroll(() => setTurnStart(nextStart)) + } + + const onScrollerScroll = () => { + if (!input.userScrolled()) return + const el = input.scroller() + if (!el) return + if (el.scrollTop >= turnScrollThreshold) return + + const start = turnStart() + if (start > 0) { + if (start <= turnPrefetchBuffer) { + void fetchOlderMessages({ prefetch: true }) + } + backfillTurns() + return + } + + void fetchOlderMessages() + } + + createEffect( + on( + input.sessionID, + () => { + setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => [input.sessionID(), input.messagesReady()] as const, + ([id, ready]) => { + if (!id || !ready) return + setTurnStart(initialTurnStart(input.visibleUserMessages().length)) + }, + { defer: true }, + ), + ) + + return { + turnStart, + setTurnStart, + renderedUserMessages, + loadAndReveal, + onScrollerScroll, + } +} + +export default function Page() { + const globalSync = useGlobalSync() + const layout = useLayout() + const local = useLocal() + const file = useFile() + const sync = useSync() + const dialog = useDialog() + const language = useLanguage() + const params = useParams() + const navigate = useNavigate() + const sdk = useSDK() + const prompt = usePrompt() + const comments = useComments() + const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() + + createEffect(() => { + if (!untrack(() => prompt.ready())) return + prompt.ready() + untrack(() => { + if (params.id || !prompt.ready()) return + const text = searchParams.prompt + if (!text) return + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + setSearchParams({ ...searchParams, prompt: undefined }) + }) + }) + + const [ui, setUi] = createStore({ + git: false, + pendingMessage: undefined as string | undefined, + reviewSnap: false, + scrollGesture: 0, + scroll: { + overflow: false, + bottom: true, + }, + }) + + const composer = createSessionComposerState() + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const workspaceKey = createMemo(() => params.dir ?? "") + const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + + createEffect( + on( + () => params.id, + (id, prev) => { + if (!id) return + if (prev) return + + const pending = layout.handoff.tabs() + if (!pending) return + if (Date.now() - pending.at > 60_000) { + layout.handoff.clearTabs() + return + } + + if (pending.id !== id) return + layout.handoff.clearTabs() + if (pending.dir !== (params.dir ?? "")) return + + const from = workspaceTabs().tabs() + if (from.all.length === 0 && !from.active) return + + const current = tabs().tabs() + if (current.all.length > 0 || current.active) return + + const all = normalizeTabs(from.all) + const active = from.active ? normalizeTab(from.active) : undefined + tabs().setAll(all) + tabs().setActive(active && all.includes(active) ? active : all[0]) + + workspaceTabs().setAll([]) + workspaceTabs().setActive(undefined) + }, + { defer: true }, + ), + ) + + const isDesktop = createMediaQuery("(min-width: 768px)") + const size = createSizing() + const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) + const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) + const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) + const sessionPanelWidth = createMemo(() => { + if (!desktopSidePanelOpen()) return "100%" + if (desktopReviewOpen()) return `${layout.session.width()}px` + return `calc(100% - ${layout.fileTree.width()}px)` + }) + const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) + + function normalizeTab(tab: string) { + if (!tab.startsWith("file://")) return tab + return file.tab(tab) + } + + function normalizeTabs(list: string[]) { + const seen = new Set() + const next: string[] = [] + for (const item of list) { + const value = normalizeTab(item) + if (seen.has(value)) continue + seen.add(value) + next.push(value) + } + return next + } + + const openReviewPanel = () => { + if (!view().reviewPanel.opened()) view().reviewPanel.open() + } + + createEffect(() => { + const active = tabs().active() + if (!active) return + + const path = file.pathFromTab(active) + if (path) file.load(path) + }) + + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasReview = createMemo(() => reviewCount() > 0) + const revertMessageID = createMemo(() => info()?.revert?.messageID) + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const messagesReady = createMemo(() => { + const id = params.id + if (!id) return true + return sync.data.message[id] !== undefined + }) + const historyMore = createMemo(() => { + const id = params.id + if (!id) return false + return sync.session.history.more(id) + }) + const historyLoading = createMemo(() => { + const id = params.id + if (!id) return false + return sync.session.history.loading(id) + }) + + const userMessages = createMemo( + () => messages().filter((m) => m.role === "user") as UserMessage[], + emptyUserMessages, + { equals: same }, + ) + const visibleUserMessages = createMemo( + () => { + const revert = revertMessageID() + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }, + emptyUserMessages, + { + equals: same, + }, + ) + const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) + + createEffect( + on( + () => lastUserMessage()?.id, + () => { + const msg = lastUserMessage() + if (!msg) return + syncSessionModel(local, msg) + }, + ), + ) + + createEffect( + on( + () => ({ dir: params.dir, id: params.id }), + (next, prev) => { + if (!prev) return + if (next.dir === prev.dir && next.id === prev.id) return + if (prev.id) sync.session.evict(prev.id, prev.dir) + if (!next.id) resetSessionModel(local) + }, + { defer: true }, + ), + ) + + const [store, setStore] = createStore({ + messageId: undefined as string | undefined, + mobileTab: "session" as "session" | "changes", + changes: "session" as "session" | "turn", + newSessionWorktree: "main", + deferRender: false, + }) + + createComputed((prev) => { + const key = sessionKey() + if (key !== prev) { + setStore("deferRender", true) + requestAnimationFrame(() => { + setTimeout(() => setStore("deferRender", false), 0) + }) + } + return key + }, sessionKey()) + + let reviewFrame: number | undefined + + createComputed((prev) => { + const open = desktopReviewOpen() + if (prev === undefined || prev === open) return open + + if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) + setUi("reviewSnap", true) + reviewFrame = requestAnimationFrame(() => { + reviewFrame = undefined + setUi("reviewSnap", false) + }) + return open + }, desktopReviewOpen()) + + const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) + const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) + + const newSessionWorktree = createMemo(() => { + if (store.newSessionWorktree === "create") return "create" + const project = sync.project + if (project && sdk.directory !== project.worktree) return sdk.directory + return "main" + }) + + const setActiveMessage = (message: UserMessage | undefined) => { + messageMark = scrollMark + setStore("messageId", message?.id) + } + + const anchor = (id: string) => `message-${id}` + + const cursor = () => { + const root = scroller + if (!root) return store.messageId + + const box = root.getBoundingClientRect() + const line = box.top + 100 + const list = [...root.querySelectorAll("[data-message-id]")] + .map((el) => { + const id = el.dataset.messageId + if (!id) return + + const rect = el.getBoundingClientRect() + return { id, top: rect.top, bottom: rect.bottom } + }) + .filter((item): item is { id: string; top: number; bottom: number } => !!item) + + const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom) + const hit = shown.find((item) => item.top <= line && item.bottom >= line) + if (hit) return hit.id + + const near = [...shown].sort((a, b) => { + const da = Math.abs(a.top - line) + const db = Math.abs(b.top - line) + if (da !== db) return da - db + return a.top - b.top + })[0] + if (near) return near.id + + return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId + } + + function navigateMessageByOffset(offset: number) { + const msgs = visibleUserMessages() + if (msgs.length === 0) return + + const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor() + const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length + const currentIndex = base === -1 ? msgs.length : base + const targetIndex = currentIndex + offset + if (targetIndex < 0 || targetIndex > msgs.length) return + + if (targetIndex === msgs.length) { + resumeScroll() + return + } + + autoScroll.pause() + scrollToMessage(msgs[targetIndex], "auto") + } + + const diffsReady = createMemo(() => { + const id = params.id + if (!id) return true + if (!hasReview()) return true + return sync.data.session_diff[id] !== undefined + }) + const reviewEmptyKey = createMemo(() => { + const project = sync.project + if (project && !project.vcs) return "session.review.noVcs" + if (sync.data.config.snapshot === false) return "session.review.noSnapshot" + return "session.review.empty" + }) + + function upsert(next: Project) { + const list = globalSync.data.project + sync.set("project", next.id) + const idx = list.findIndex((item) => item.id === next.id) + if (idx >= 0) { + globalSync.set( + "project", + list.map((item, i) => (i === idx ? { ...item, ...next } : item)), + ) + return + } + const at = list.findIndex((item) => item.id > next.id) + if (at >= 0) { + globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)]) + return + } + globalSync.set("project", [...list, next]) + } + + function initGit() { + if (ui.git) return + setUi("git", true) + void sdk.client.project + .initGit() + .then((x) => { + if (!x.data) return + upsert(x.data) + }) + .catch((err) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: formatServerError(err, language.t), + }) + }) + .finally(() => { + setUi("git", false) + }) + } + + let inputRef!: HTMLDivElement + let promptDock: HTMLDivElement | undefined + let dockHeight = 0 + let scroller: HTMLDivElement | undefined + let content: HTMLDivElement | undefined + let scrollMark = 0 + let messageMark = 0 + + const scrollGestureWindowMs = 250 + + const markScrollGesture = (target?: EventTarget | null) => { + const root = scroller + if (!root) return + + const el = target instanceof Element ? target : undefined + const nested = el?.closest("[data-scrollable]") + if (nested && nested !== root) return + + setUi("scrollGesture", Date.now()) + } + + const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs + + createEffect( + on([() => sdk.directory, () => params.id] as const, ([, id]) => { + if (!id) return + untrack(() => { + void sync.session.sync(id) + void sync.session.todo(id) + }) + }), + ) + + createEffect( + on( + () => visibleUserMessages().at(-1)?.id, + (lastId, prevLastId) => { + if (lastId && prevLastId && lastId > prevLastId) { + setStore("messageId", undefined) + } + }, + { defer: true }, + ), + ) + + createEffect( + on( + sessionKey, + () => { + setStore("messageId", undefined) + setStore("changes", "session") + setUi("pendingMessage", undefined) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => params.dir, + (dir) => { + if (!dir) return + setStore("newSessionWorktree", "main") + }, + { defer: true }, + ), + ) + + const selectionPreview = (path: string, selection: FileSelection) => { + const content = file.get(path)?.content?.content + if (!content) return undefined + const start = Math.max(1, Math.min(selection.startLine, selection.endLine)) + const end = Math.max(selection.startLine, selection.endLine) + const lines = content.split("\n").slice(start - 1, end) + if (lines.length === 0) return undefined + return lines.slice(0, 2).join("\n") + } + + const addCommentToContext = (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => { + const selection = selectionFromLines(input.selection) + const preview = input.preview ?? selectionPreview(input.file, selection) + const saved = comments.add({ + file: input.file, + selection: input.selection, + comment: input.comment, + }) + prompt.context.add({ + type: "file", + path: input.file, + selection, + comment: input.comment, + commentID: saved.id, + commentOrigin: input.origin, + preview, + }) + } + + const updateCommentInContext = (input: { + id: string + file: string + selection: SelectedLineRange + comment: string + preview?: string + }) => { + comments.update(input.file, input.id, input.comment) + prompt.context.updateComment(input.file, input.id, { + comment: input.comment, + ...(input.preview ? { preview: input.preview } : {}), + }) + } + + const removeCommentFromContext = (input: { id: string; file: string }) => { + comments.remove(input.file, input.id) + prompt.context.removeComment(input.file, input.id) + } + + const reviewCommentActions = createMemo(() => ({ + moreLabel: language.t("common.moreOptions"), + editLabel: language.t("common.edit"), + deleteLabel: language.t("common.delete"), + saveLabel: language.t("common.save"), + })) + + const isEditableTarget = (target: EventTarget | null | undefined) => { + if (!(target instanceof HTMLElement)) return false + return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable + } + + const deepActiveElement = () => { + let current: Element | null = document.activeElement + while (current instanceof HTMLElement && current.shadowRoot?.activeElement) { + current = current.shadowRoot.activeElement + } + return current instanceof HTMLElement ? current : undefined + } + + const handleKeyDown = (event: KeyboardEvent) => { + const path = event.composedPath() + const target = path.find((item): item is HTMLElement => item instanceof HTMLElement) + const activeElement = deepActiveElement() + + const protectedTarget = path.some( + (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null, + ) + if (protectedTarget || isEditableTarget(target)) return + + if (activeElement) { + const isProtected = activeElement.closest("[data-prevent-autofocus]") + const isInput = isEditableTarget(activeElement) + if (isProtected || isInput) return + } + if (dialog.active) return + + if (activeElement === inputRef) { + if (event.key === "Escape") inputRef?.blur() + return + } + + // Don't autofocus chat if desktop terminal panel is open + if (isDesktop() && view().terminal.opened()) return + + // Only treat explicit scroll keys as potential "user scroll" gestures. + if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { + markScrollGesture() + return + } + + if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { + if (composer.blocked()) return + inputRef?.focus() + } + } + + const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) + const openedTabs = createMemo(() => + tabs() + .all() + .filter((tab) => tab !== "context" && tab !== "review"), + ) + + const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") + const reviewTab = createMemo(() => isDesktop()) + + const fileTreeTab = () => layout.fileTree.tab() + const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) + + const [tree, setTree] = createStore({ + reviewScroll: undefined as HTMLDivElement | undefined, + pendingDiff: undefined as string | undefined, + activeDiff: undefined as string | undefined, + }) + + createEffect( + on( + sessionKey, + () => { + setTree({ + reviewScroll: undefined, + pendingDiff: undefined, + activeDiff: undefined, + }) + }, + { defer: true }, + ), + ) + + const showAllFiles = () => { + if (fileTreeTab() !== "changes") return + setFileTreeTab("all") + } + + const focusInput = () => inputRef?.focus() + + useSessionCommands({ + navigateMessageByOffset, + setActiveMessage, + focusInput, + }) + + const openReviewFile = createOpenReviewFile({ + showAllFiles, + tabForPath: file.tab, + openTab: tabs().open, + setActive: tabs().setActive, + loadFile: file.load, + }) + + const changesOptions = ["session", "turn"] as const + const changesOptionsList = [...changesOptions] + + const changesTitle = () => { + if (!hasReview()) { + return null + } + + return ( +
` + + const focused = focusTerminalById("one") + + expect(focused).toBe(true) + expect(document.activeElement?.tagName).toBe("TEXTAREA") + }) + + test("falls back to terminal element focus", () => { + document.body.innerHTML = `
` + const terminal = document.querySelector('[data-component="terminal"]') as HTMLElement + let pointerDown = false + terminal.addEventListener("pointerdown", () => { + pointerDown = true + }) + + const focused = focusTerminalById("two") + + expect(focused).toBe(true) + expect(document.activeElement).toBe(terminal) + expect(pointerDown).toBe(true) + }) +}) + +describe("getTabReorderIndex", () => { + test("returns target index for valid drag reorder", () => { + expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2) + }) + + test("returns undefined for unknown droppable id", () => { + expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined() + }) +}) diff --git a/Agent-backend/packages/app/src/pages/session/helpers.ts b/Agent-backend/packages/app/src/pages/session/helpers.ts new file mode 100644 index 000000000..be9656900 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/helpers.ts @@ -0,0 +1,173 @@ +import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" + +export const focusTerminalById = (id: string) => { + const wrapper = document.getElementById(`terminal-wrapper-${id}`) + const terminal = wrapper?.querySelector('[data-component="terminal"]') + if (!(terminal instanceof HTMLElement)) return false + + const textarea = terminal.querySelector("textarea") + if (textarea instanceof HTMLTextAreaElement) { + textarea.focus() + return true + } + + terminal.focus() + terminal.dispatchEvent( + typeof PointerEvent === "function" + ? new PointerEvent("pointerdown", { bubbles: true, cancelable: true }) + : new MouseEvent("pointerdown", { bubbles: true, cancelable: true }), + ) + return true +} + +export const createOpenReviewFile = (input: { + showAllFiles: () => void + tabForPath: (path: string) => string + openTab: (tab: string) => void + setActive: (tab: string) => void + loadFile: (path: string) => any | Promise +}) => { + return (path: string) => { + batch(() => { + input.showAllFiles() + const maybePromise = input.loadFile(path) + const open = () => { + const tab = input.tabForPath(path) + input.openTab(tab) + input.setActive(tab) + } + if (maybePromise instanceof Promise) maybePromise.then(open) + else open() + }) + } +} + +export const createOpenSessionFileTab = (input: { + normalizeTab: (tab: string) => string + openTab: (tab: string) => void + pathFromTab: (tab: string) => string | undefined + loadFile: (path: string) => void + openReviewPanel: () => void + setActive: (tab: string) => void +}) => { + return (value: string) => { + const next = input.normalizeTab(value) + input.openTab(next) + + const path = input.pathFromTab(next) + if (!path) return + + input.loadFile(path) + input.openReviewPanel() + input.setActive(next) + } +} + +export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => { + const fromIndex = tabs.indexOf(from) + const toIndex = tabs.indexOf(to) + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined + return toIndex +} + +export const createSizing = () => { + const [state, setState] = createStore({ active: false }) + let t: number | undefined + + const stop = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", false) + } + + const start = () => { + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + setState("active", true) + } + + onMount(() => { + window.addEventListener("pointerup", stop) + window.addEventListener("pointercancel", stop) + window.addEventListener("blur", stop) + onCleanup(() => { + window.removeEventListener("pointerup", stop) + window.removeEventListener("pointercancel", stop) + window.removeEventListener("blur", stop) + }) + }) + + onCleanup(() => { + if (t !== undefined) clearTimeout(t) + }) + + return { + active: () => state.active, + start, + touch() { + start() + t = window.setTimeout(stop, 120) + }, + } +} + +export type Sizing = ReturnType + +export const createPresence = (open: Accessor, wait = 200) => { + const [state, setState] = createStore({ + show: open(), + open: open(), + }) + let frame: number | undefined + let t: number | undefined + + const clear = () => { + if (frame !== undefined) { + cancelAnimationFrame(frame) + frame = undefined + } + if (t !== undefined) { + clearTimeout(t) + t = undefined + } + } + + createEffect( + on(open, (next) => { + clear() + + if (next) { + if (state.show) { + setState("open", true) + return + } + + setState({ show: true, open: false }) + frame = requestAnimationFrame(() => { + frame = undefined + setState("open", true) + }) + return + } + + if (!state.show) return + setState("open", false) + t = window.setTimeout(() => { + t = undefined + setState("show", false) + }, wait) + }), + ) + + onCleanup(clear) + + return { + show: () => state.show, + open: () => state.open, + } +} diff --git a/Agent-backend/packages/app/src/pages/session/message-gesture.test.ts b/Agent-backend/packages/app/src/pages/session/message-gesture.test.ts new file mode 100644 index 000000000..b2af4bb83 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/message-gesture.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "./message-gesture" + +describe("normalizeWheelDelta", () => { + test("converts line mode to px", () => { + expect(normalizeWheelDelta({ deltaY: 3, deltaMode: 1, rootHeight: 500 })).toBe(120) + }) + + test("converts page mode to container height", () => { + expect(normalizeWheelDelta({ deltaY: -1, deltaMode: 2, rootHeight: 600 })).toBe(-600) + }) + + test("keeps pixel mode unchanged", () => { + expect(normalizeWheelDelta({ deltaY: 16, deltaMode: 0, rootHeight: 600 })).toBe(16) + }) +}) + +describe("shouldMarkBoundaryGesture", () => { + test("marks when nested scroller cannot scroll", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 20, + scrollTop: 0, + scrollHeight: 300, + clientHeight: 300, + }), + ).toBe(true) + }) + + test("marks when scrolling beyond top boundary", () => { + expect( + shouldMarkBoundaryGesture({ + delta: -40, + scrollTop: 10, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(true) + }) + + test("marks when scrolling beyond bottom boundary", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 50, + scrollTop: 580, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(true) + }) + + test("does not mark when nested scroller can consume movement", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 20, + scrollTop: 200, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(false) + }) +}) diff --git a/Agent-backend/packages/app/src/pages/session/message-gesture.ts b/Agent-backend/packages/app/src/pages/session/message-gesture.ts new file mode 100644 index 000000000..731cb1bde --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/message-gesture.ts @@ -0,0 +1,21 @@ +export const normalizeWheelDelta = (input: { deltaY: number; deltaMode: number; rootHeight: number }) => { + if (input.deltaMode === 1) return input.deltaY * 40 + if (input.deltaMode === 2) return input.deltaY * input.rootHeight + return input.deltaY +} + +export const shouldMarkBoundaryGesture = (input: { + delta: number + scrollTop: number + scrollHeight: number + clientHeight: number +}) => { + const max = input.scrollHeight - input.clientHeight + if (max <= 1) return true + if (!input.delta) return false + + if (input.delta < 0) return input.scrollTop + input.delta <= 0 + + const remaining = max - input.scrollTop + return input.delta > remaining +} diff --git a/Agent-backend/packages/app/src/pages/session/message-id-from-hash.ts b/Agent-backend/packages/app/src/pages/session/message-id-from-hash.ts new file mode 100644 index 000000000..2857f4b01 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/message-id-from-hash.ts @@ -0,0 +1,6 @@ +export const messageIdFromHash = (hash: string) => { + const value = hash.startsWith("#") ? hash.slice(1) : hash + const match = value.match(/^message-(.+)$/) + if (!match) return + return match[1] +} diff --git a/Agent-backend/packages/app/src/pages/session/message-timeline.tsx b/Agent-backend/packages/app/src/pages/session/message-timeline.tsx new file mode 100644 index 000000000..6463e7cbb --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/message-timeline.tsx @@ -0,0 +1,829 @@ +import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useNavigate, useParams } from "@solidjs/router" +import { Button } from "@opencode-ai/ui/button" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { InlineInput } from "@opencode-ai/ui/inline-input" +import { Spinner } from "@opencode-ai/ui/spinner" +import { SessionTurn } from "@opencode-ai/ui/session-turn" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import { showToast } from "@opencode-ai/ui/toast" +import { Binary } from "@opencode-ai/util/binary" +import { getFilename } from "@opencode-ai/util/path" +import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +import { SessionContextUsage } from "@/components/session-context-usage" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" + +type MessageComment = { + path: string + comment: string + selection?: { + startLine: number + endLine: number + } +} + +const emptyMessages: MessageType[] = [] +const idle = { type: "idle" as const } + +const messageComments = (parts: Part[]): MessageComment[] => + parts.flatMap((part) => { + if (part.type !== "text" || !(part as TextPart).synthetic) return [] + const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text) + if (!next) return [] + return [ + { + path: next.path, + comment: next.comment, + selection: next.selection + ? { + startLine: next.selection.startLine, + endLine: next.selection.endLine, + } + : undefined, + }, + ] + }) + +const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { + const current = target instanceof Element ? target : undefined + const nested = current?.closest("[data-scrollable]") + if (!nested || nested === root) return root + if (!(nested instanceof HTMLElement)) return root + return nested +} + +const markBoundaryGesture = (input: { + root: HTMLDivElement + target: EventTarget | null + delta: number + onMarkScrollGesture: (target?: EventTarget | null) => void +}) => { + const target = boundaryTarget(input.root, input.target) + if (target === input.root) { + input.onMarkScrollGesture(input.root) + return + } + if ( + shouldMarkBoundaryGesture({ + delta: input.delta, + scrollTop: target.scrollTop, + scrollHeight: target.scrollHeight, + clientHeight: target.clientHeight, + }) + ) { + input.onMarkScrollGesture(input.root) + } +} + +type StageConfig = { + init: number + batch: number +} + +type TimelineStageInput = { + sessionKey: () => string + turnStart: () => number + messages: () => UserMessage[] + config: StageConfig +} + +/** + * Defer-mounts small timeline windows so revealing older turns does not + * block first paint with a large DOM mount. + * + * Once staging completes for a session it never re-stages — backfill and + * new messages render immediately. + */ +function createTimelineStaging(input: TimelineStageInput) { + const [state, setState] = createStore({ + activeSession: "", + completedSession: "", + count: 0, + }) + + const stagedCount = createMemo(() => { + const total = input.messages().length + if (input.turnStart() <= 0) return total + if (state.completedSession === input.sessionKey()) return total + const init = Math.min(total, input.config.init) + if (state.count <= init) return init + if (state.count >= total) return total + return state.count + }) + + const stagedUserMessages = createMemo(() => { + const list = input.messages() + const count = stagedCount() + if (count >= list.length) return list + return list.slice(Math.max(0, list.length - count)) + }) + + let frame: number | undefined + const cancel = () => { + if (frame === undefined) return + cancelAnimationFrame(frame) + frame = undefined + } + + createEffect( + on( + () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, + ([sessionKey, isWindowed, total]) => { + cancel() + const shouldStage = + isWindowed && + total > input.config.init && + state.completedSession !== sessionKey && + state.activeSession !== sessionKey + if (!shouldStage) { + setState({ activeSession: "", count: total }) + return + } + + let count = Math.min(total, input.config.init) + setState({ activeSession: sessionKey, count }) + + const step = () => { + if (input.sessionKey() !== sessionKey) { + frame = undefined + return + } + const currentTotal = input.messages().length + count = Math.min(currentTotal, count + input.config.batch) + setState("count", count) + if (count >= currentTotal) { + setState({ completedSession: sessionKey, activeSession: "" }) + frame = undefined + return + } + frame = requestAnimationFrame(step) + } + frame = requestAnimationFrame(step) + }, + ), + ) + + const isStaging = createMemo(() => { + const key = input.sessionKey() + return state.activeSession === key && state.completedSession !== key + }) + + onCleanup(cancel) + return { messages: stagedUserMessages, isStaging } +} + +export function MessageTimeline(props: { + mobileChanges: boolean + mobileFallback: JSX.Element + scroll: { overflow: boolean; bottom: boolean } + onResumeScroll: () => void + setScrollRef: (el: HTMLDivElement | undefined) => void + onScheduleScrollState: (el: HTMLDivElement) => void + onAutoScrollHandleScroll: () => void + onMarkScrollGesture: (target?: EventTarget | null) => void + hasScrollGesture: () => boolean + onUserScroll: () => void + onTurnBackfillScroll: () => void + onAutoScrollInteraction: (event: MouseEvent) => void + centered: boolean + setContentRef: (el: HTMLDivElement) => void + turnStart: number + historyMore: boolean + historyLoading: boolean + onLoadEarlier: () => void + renderedUserMessages: UserMessage[] + anchor: (id: string) => string +}) { + let touchGesture: number | undefined + + const params = useParams() + const navigate = useNavigate() + const sdk = useSDK() + const sync = useSync() + const settings = useSettings() + const dialog = useDialog() + const language = useLanguage() + + const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const sessionID = createMemo(() => params.id) + const sessionMessages = createMemo(() => { + const id = sessionID() + if (!id) return emptyMessages + return sync.data.message[id] ?? emptyMessages + }) + const pending = createMemo(() => + sessionMessages().findLast( + (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", + ), + ) + const sessionStatus = createMemo(() => { + const id = sessionID() + if (!id) return idle + return sync.data.session_status[id] ?? idle + }) + const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") + + const [slot, setSlot] = createStore({ + open: false, + show: false, + fade: false, + }) + + let f: number | undefined + const clear = () => { + if (f !== undefined) window.clearTimeout(f) + f = undefined + } + + onCleanup(clear) + createEffect( + on( + working, + (on, prev) => { + clear() + if (on) { + setSlot({ open: true, show: true, fade: false }) + return + } + if (prev) { + setSlot({ open: false, show: true, fade: true }) + f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260) + return + } + setSlot({ open: false, show: false, fade: false }) + }, + { defer: true }, + ), + ) + const activeMessageID = createMemo(() => { + const parentID = pending()?.parentID + if (parentID) { + const messages = sessionMessages() + const result = Binary.search(messages, parentID, (message) => message.id) + const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) + if (message && message.role === "user") return message.id + } + + const status = sessionStatus() + if (status.type !== "idle") { + const messages = sessionMessages() + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i].id + } + } + + return undefined + }) + const info = createMemo(() => { + const id = sessionID() + if (!id) return + return sync.session.get(id) + }) + const titleValue = createMemo(() => info()?.title) + const parentID = createMemo(() => info()?.parentID) + const showHeader = createMemo(() => !!(titleValue() || parentID())) + const stageCfg = { init: 1, batch: 3 } + const staging = createTimelineStaging({ + sessionKey, + turnStart: () => props.turnStart, + messages: () => props.renderedUserMessages, + config: stageCfg, + }) + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + let titleRef: HTMLInputElement | undefined + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + createEffect( + on( + sessionKey, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!sessionID()) return + setTitle({ editing: true, draft: titleValue() ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const id = sessionID() + if (!id) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (titleValue() ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID: id, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === id) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== sessionID) return + if (parentID) { + navigate(`/${params.dir}/session/${parentID}`) + return + } + if (nextSessionID) { + navigate(`/${params.dir}/session/${nextSessionID}`) + return + } + navigate(`/${params.dir}/session`) + } + + const archiveSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + const deleteSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set([sessionID]) + + const byParent = new Map() + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + return true + } + + const navigateParent = () => { + const id = parentID() + if (!id) return + navigate(`/${params.dir}/session/${id}`) + } + + function DialogDeleteSession(props: { sessionID: string }) { + const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) + const handleDelete = async () => { + await deleteSession(props.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } + + return ( + {props.mobileFallback}
} + > +
+
+ +
+ { + const root = e.currentTarget + const delta = normalizeWheelDelta({ + deltaY: e.deltaY, + deltaMode: e.deltaMode, + rootHeight: root.clientHeight, + }) + if (!delta) return + markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) + }} + onTouchStart={(e) => { + touchGesture = e.touches[0]?.clientY + }} + onTouchMove={(e) => { + const next = e.touches[0]?.clientY + const prev = touchGesture + touchGesture = next + if (next === undefined || prev === undefined) return + + const delta = prev - next + if (!delta) return + + const root = e.currentTarget + markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) + }} + onTouchEnd={() => { + touchGesture = undefined + }} + onTouchCancel={() => { + touchGesture = undefined + }} + onPointerDown={(e) => { + if (e.target !== e.currentTarget) return + props.onMarkScrollGesture(e.currentTarget) + }} + onScroll={(e) => { + props.onScheduleScrollState(e.currentTarget) + props.onTurnBackfillScroll() + if (!props.hasScrollGesture()) return + props.onUserScroll() + props.onAutoScrollHandleScroll() + props.onMarkScrollGesture(e.currentTarget) + }} + onClick={props.onAutoScrollInteraction} + class="relative min-w-0 w-full h-full" + style={{ + "--session-title-height": showHeader() ? "40px" : "0px", + "--sticky-accordion-top": showHeader() ? "48px" : "0px", + }} + > +
+ +
+
+
+ + + +
+ + + + {titleValue()} + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={closeTitleEditor} + /> + + +
+
+ + {(id) => ( +
+ + setTitle("menuOpen", open)} + > + + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )} + > + {language.t("common.delete")} + + + + +
+ )} +
+
+
+
+ +
+ 0 || props.historyMore}> +
+ +
+
+ + {(messageID) => { + const active = createMemo(() => activeMessageID() === messageID) + const queued = createMemo(() => { + if (active()) return false + const activeID = activeMessageID() + if (activeID) return messageID > activeID + return false + }) + const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { + equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), + }) + const commentCount = createMemo(() => comments().length) + return ( +
+ 0}> +
+
+
+ + {(commentAccessor: () => MessageComment) => { + const comment = createMemo(() => commentAccessor()) + return ( +
+
+ + {getFilename(comment().path)} + + {(selection) => ( + + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + + )} + +
+
+ {comment().comment} +
+
+ ) + }} +
+
+
+
+
+ +
+ ) + }} +
+
+
+
+
+ + ) +} diff --git a/Agent-backend/packages/app/src/pages/session/review-tab.tsx b/Agent-backend/packages/app/src/pages/session/review-tab.tsx new file mode 100644 index 000000000..142ee7ad9 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/review-tab.tsx @@ -0,0 +1,178 @@ +import { createEffect, onCleanup, type JSX } from "solid-js" +import type { FileDiff } from "@opencode-ai/sdk/v2" +import { SessionReview } from "@opencode-ai/ui/session-review" +import type { + SessionReviewCommentActions, + SessionReviewCommentDelete, + SessionReviewCommentUpdate, +} from "@opencode-ai/ui/session-review" +import type { SelectedLineRange } from "@/context/file" +import { useSDK } from "@/context/sdk" +import { useLayout } from "@/context/layout" +import type { LineComment } from "@/context/comments" + +export type DiffStyle = "unified" | "split" + +export interface SessionReviewTabProps { + title?: JSX.Element + empty?: JSX.Element + diffs: () => FileDiff[] + view: () => ReturnType["view"]> + diffStyle: DiffStyle + onDiffStyleChange?: (style: DiffStyle) => void + onViewFile?: (file: string) => void + onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void + onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void + onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void + lineCommentActions?: SessionReviewCommentActions + comments?: LineComment[] + focusedComment?: { file: string; id: string } | null + onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void + focusedFile?: string + onScrollRef?: (el: HTMLDivElement) => void + classes?: { + root?: string + header?: string + container?: string + } +} + +export function StickyAddButton(props: { children: JSX.Element }) { + return ( +
+ {props.children} +
+ ) +} + +export function SessionReviewTab(props: SessionReviewTabProps) { + let scroll: HTMLDivElement | undefined + let restoreFrame: number | undefined + let userInteracted = false + let restored: { x: number; y: number } | undefined + + const sdk = useSDK() + const layout = useLayout() + + const readFile = async (path: string) => { + return sdk.client.file + .read({ path }) + .then((x) => x.data) + .catch((error) => { + console.debug("[session-review] failed to read file", { path, error }) + return undefined + }) + } + + const handleInteraction = () => { + userInteracted = true + + if (restoreFrame !== undefined) { + cancelAnimationFrame(restoreFrame) + restoreFrame = undefined + } + } + + const doRestore = () => { + restoreFrame = undefined + const el = scroll + if (!el || !layout.ready() || userInteracted) return + if (el.clientHeight === 0 || el.clientWidth === 0) return + + const s = props.view().scroll("review") + if (!s || (s.x === 0 && s.y === 0)) return + + const maxY = Math.max(0, el.scrollHeight - el.clientHeight) + const maxX = Math.max(0, el.scrollWidth - el.clientWidth) + + const targetY = Math.min(s.y, maxY) + const targetX = Math.min(s.x, maxX) + + if (el.scrollTop === targetY && el.scrollLeft === targetX) return + + if (el.scrollTop !== targetY) el.scrollTop = targetY + if (el.scrollLeft !== targetX) el.scrollLeft = targetX + restored = { x: el.scrollLeft, y: el.scrollTop } + } + + const queueRestore = () => { + if (userInteracted || restoreFrame !== undefined) return + restoreFrame = requestAnimationFrame(doRestore) + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + const el = event.currentTarget + const prev = restored + if (prev && el.scrollTop === prev.y && el.scrollLeft === prev.x) { + restored = undefined + return + } + + restored = undefined + handleInteraction() + if (!layout.ready()) return + if (el.clientHeight === 0 || el.clientWidth === 0) return + + props.view().setScroll("review", { + x: el.scrollLeft, + y: el.scrollTop, + }) + } + + createEffect(() => { + props.diffs().length + props.diffStyle + if (!layout.ready()) return + queueRestore() + }) + + onCleanup(() => { + if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) + if (scroll) { + scroll.removeEventListener("wheel", handleInteraction, { capture: true }) + scroll.removeEventListener("mousewheel", handleInteraction, { capture: true }) + scroll.removeEventListener("pointerdown", handleInteraction, { capture: true }) + scroll.removeEventListener("touchstart", handleInteraction, { capture: true }) + scroll.removeEventListener("keydown", handleInteraction, { capture: true }) + } + }) + + return ( + { + scroll = el + el.addEventListener("wheel", handleInteraction, { passive: true, capture: true }) + el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true }) + el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true }) + el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true }) + el.addEventListener("keydown", handleInteraction, { passive: true, capture: true }) + props.onScrollRef?.(el) + queueRestore() + }} + onScroll={handleScroll} + onDiffRendered={queueRestore} + open={props.view().review.open()} + onOpenChange={props.view().review.setOpen} + classes={{ + root: props.classes?.root ?? "pr-3", + header: props.classes?.header ?? "px-3", + container: props.classes?.container ?? "pl-3", + }} + diffs={props.diffs()} + diffStyle={props.diffStyle} + onDiffStyleChange={props.onDiffStyleChange} + onViewFile={props.onViewFile} + focusedFile={props.focusedFile} + readFile={readFile} + onLineComment={props.onLineComment} + onLineCommentUpdate={props.onLineCommentUpdate} + onLineCommentDelete={props.onLineCommentDelete} + lineCommentActions={props.lineCommentActions} + comments={props.comments} + focusedComment={props.focusedComment} + onFocusedCommentChange={props.onFocusedCommentChange} + /> + ) +} diff --git a/Agent-backend/packages/app/src/pages/session/session-command-helpers.ts b/Agent-backend/packages/app/src/pages/session/session-command-helpers.ts new file mode 100644 index 000000000..b71a7b768 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/session-command-helpers.ts @@ -0,0 +1,10 @@ +export const canAddSelectionContext = (input: { + active?: string + pathFromTab: (tab: string) => string | undefined + selectedLines: (path: string) => unknown +}) => { + if (!input.active) return false + const path = input.pathFromTab(input.active) + if (!path) return false + return input.selectedLines(path) != null +} diff --git a/Agent-backend/packages/app/src/pages/session/session-mobile-tabs.tsx b/Agent-backend/packages/app/src/pages/session/session-mobile-tabs.tsx new file mode 100644 index 000000000..f97199b49 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/session-mobile-tabs.tsx @@ -0,0 +1,41 @@ +import { Show } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" +import { useLanguage } from "@/context/language" + +export function SessionMobileTabs(props: { + open: boolean + mobileTab: "session" | "changes" + hasReview: boolean + reviewCount: number + onSession: () => void + onChanges: () => void +}) { + const language = useLanguage() + + return ( + + + + + {language.t("session.tab.session")} + + + {props.hasReview + ? language.t("session.review.filesChanged", { count: props.reviewCount }) + : language.t("session.review.change.other")} + + + + + ) +} diff --git a/Agent-backend/packages/app/src/pages/session/session-model-helpers.test.ts b/Agent-backend/packages/app/src/pages/session/session-model-helpers.test.ts new file mode 100644 index 000000000..5f554dcd3 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/session-model-helpers.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { resetSessionModel, syncSessionModel } from "./session-model-helpers" + +const message = (input?: Partial>) => + ({ + id: "msg", + sessionID: "session", + role: "user", + time: { created: 1 }, + agent: input?.agent ?? "build", + model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: input?.variant, + }) as UserMessage + +describe("syncSessionModel", () => { + test("restores the last message model and variant", () => { + const calls: unknown[] = [] + + syncSessionModel( + { + agent: { + current() { + return undefined + }, + set(value) { + calls.push(["agent", value]) + }, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return { id: "claude-sonnet-4", provider: { id: "anthropic" } } + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }, + message({ variant: "high" }), + ) + + expect(calls).toEqual([ + ["agent", "build"], + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ["variant", "high"], + ]) + }) + + test("skips variant when the model falls back", () => { + const calls: unknown[] = [] + + syncSessionModel( + { + agent: { + current() { + return undefined + }, + set(value) { + calls.push(["agent", value]) + }, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return { id: "gpt-5", provider: { id: "openai" } } + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }, + message({ variant: "high" }), + ) + + expect(calls).toEqual([ + ["agent", "build"], + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ]) + }) +}) + +describe("resetSessionModel", () => { + test("restores the current agent defaults", () => { + const calls: unknown[] = [] + + resetSessionModel({ + agent: { + current() { + return { + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + variant: "high", + } + }, + set() {}, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return undefined + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }) + + expect(calls).toEqual([ + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ["variant", "high"], + ]) + }) + + test("clears the variant when the agent has none", () => { + const calls: unknown[] = [] + + resetSessionModel({ + agent: { + current() { + return { + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + } + }, + set() {}, + }, + model: { + set(value) { + calls.push(["model", value]) + }, + current() { + return undefined + }, + variant: { + set(value) { + calls.push(["variant", value]) + }, + }, + }, + }) + + expect(calls).toEqual([ + ["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }], + ["variant", undefined], + ]) + }) +}) diff --git a/Agent-backend/packages/app/src/pages/session/session-model-helpers.ts b/Agent-backend/packages/app/src/pages/session/session-model-helpers.ts new file mode 100644 index 000000000..7600f16d5 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/session-model-helpers.ts @@ -0,0 +1,48 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { batch } from "solid-js" + +type Local = { + agent: { + current(): + | { + model?: UserMessage["model"] + variant?: string + } + | undefined + set(name: string | undefined): void + } + model: { + set(model: UserMessage["model"] | undefined): void + current(): + | { + id: string + provider: { id: string } + } + | undefined + variant: { + set(value: string | undefined): void + } + } +} + +export const resetSessionModel = (local: Local) => { + const agent = local.agent.current() + if (!agent) return + batch(() => { + local.model.set(agent.model) + local.model.variant.set(agent.variant) + }) +} + +export const syncSessionModel = (local: Local, msg: UserMessage) => { + batch(() => { + local.agent.set(msg.agent) + local.model.set(msg.model) + }) + + const model = local.model.current() + if (!model) return + if (model.provider.id !== msg.model.providerID) return + if (model.id !== msg.model.modelID) return + local.model.variant.set(msg.variant) +} diff --git a/Agent-backend/packages/app/src/pages/session/session-prompt-dock.test.ts b/Agent-backend/packages/app/src/pages/session/session-prompt-dock.test.ts new file mode 100644 index 000000000..b3a9945d6 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/session-prompt-dock.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { questionSubtitle } from "./session-prompt-helpers" + +describe("questionSubtitle", () => { + const t = (key: string) => { + if (key === "ui.common.question.one") return "question" + if (key === "ui.common.question.other") return "questions" + return key + } + + test("returns empty for zero", () => { + expect(questionSubtitle(0, t)).toBe("") + }) + + test("uses singular label", () => { + expect(questionSubtitle(1, t)).toBe("1 question") + }) + + test("uses plural label", () => { + expect(questionSubtitle(3, t)).toBe("3 questions") + }) +}) diff --git a/Agent-backend/packages/app/src/pages/session/session-prompt-helpers.ts b/Agent-backend/packages/app/src/pages/session/session-prompt-helpers.ts new file mode 100644 index 000000000..ac3234c93 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/session-prompt-helpers.ts @@ -0,0 +1,4 @@ +export const questionSubtitle = (count: number, t: (key: string) => string) => { + if (count === 0) return "" + return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` +} diff --git a/Agent-backend/packages/app/src/pages/session/session-side-panel.tsx b/Agent-backend/packages/app/src/pages/session/session-side-panel.tsx new file mode 100644 index 000000000..590f5b6d9 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/session-side-panel.tsx @@ -0,0 +1,473 @@ +import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { createMediaQuery } from "@solid-primitives/media" +import { useParams } from "@solidjs/router" +import { Tabs } from "@opencode-ai/ui/tabs" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Mark } from "@opencode-ai/ui/logo" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" +import { useDialog } from "@opencode-ai/ui/context/dialog" + +import FileTree from "@/components/file-tree" +import { SessionContextUsage } from "@/components/session-context-usage" +import { DialogSelectFile } from "@/components/dialog-select-file" +import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" +import { useCommand } from "@/context/command" +import { useFile, type SelectedLineRange } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useSync } from "@/context/sync" +import { createFileTabListSync } from "@/pages/session/file-tab-scroll" +import { FileTabContent } from "@/pages/session/file-tabs" +import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" +import { StickyAddButton } from "@/pages/session/review-tab" +import { setSessionHandoff } from "@/pages/session/handoff" + +export function SessionSidePanel(props: { + reviewPanel: () => JSX.Element + activeDiff?: string + focusReviewDiff: (path: string) => void + reviewSnap: boolean + size: Sizing +}) { + const params = useParams() + const layout = useLayout() + const sync = useSync() + const file = useFile() + const language = useLanguage() + const command = useCommand() + const dialog = useDialog() + + const isDesktop = createMediaQuery("(min-width: 768px)") + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + + const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) + const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) + const open = createMemo(() => reviewOpen() || fileOpen()) + const reviewTab = createMemo(() => isDesktop()) + const panelWidth = createMemo(() => { + if (!open()) return "0px" + if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)` + return `${layout.fileTree.width()}px` + }) + const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) + + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasReview = createMemo(() => reviewCount() > 0) + const diffsReady = createMemo(() => { + const id = params.id + if (!id) return true + if (!hasReview()) return true + return sync.data.session_diff[id] !== undefined + }) + + const reviewEmptyKey = createMemo(() => { + if (sync.project && !sync.project.vcs) return "session.review.noVcs" + if (sync.data.config.snapshot === false) return "session.review.noSnapshot" + return "session.review.noChanges" + }) + + const diffFiles = createMemo(() => diffs().map((d) => d.file)) + const kinds = createMemo(() => { + const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { + if (!a) return b + if (a === b) return a + return "mix" as const + } + + const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") + + const out = new Map() + for (const diff of diffs()) { + const file = normalize(diff.file) + const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" + + out.set(file, kind) + + const parts = file.split("/") + for (const [idx] of parts.slice(0, -1).entries()) { + const dir = parts.slice(0, idx + 1).join("/") + if (!dir) continue + out.set(dir, merge(out.get(dir), kind)) + } + } + return out + }) + + const empty = (msg: string) => ( +
+
+
+
{msg}
+
+
+ ) + + const nofiles = createMemo(() => { + const state = file.tree.state("") + if (!state?.loaded) return false + return file.tree.children("").length === 0 + }) + + const normalizeTab = (tab: string) => { + if (!tab.startsWith("file://")) return tab + return file.tab(tab) + } + + const openReviewPanel = () => { + if (!view().reviewPanel.opened()) view().reviewPanel.open() + } + + const openTab = createOpenSessionFileTab({ + normalizeTab, + openTab: tabs().open, + pathFromTab: file.pathFromTab, + loadFile: file.load, + openReviewPanel, + setActive: tabs().setActive, + }) + + const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) + const openedTabs = createMemo(() => + tabs() + .all() + .filter((tab) => tab !== "context" && tab !== "review"), + ) + + const activeTab = createMemo(() => { + const active = tabs().active() + if (active === "context") return "context" + if (active === "review" && reviewTab()) return "review" + if (active && file.pathFromTab(active)) return normalizeTab(active) + + const first = openedTabs()[0] + if (first) return first + if (contextOpen()) return "context" + if (reviewTab() && hasReview()) return "review" + return "empty" + }) + + const activeFileTab = createMemo(() => { + const active = activeTab() + if (!openedTabs().includes(active)) return + return active + }) + + const fileTreeTab = () => layout.fileTree.tab() + + const setFileTreeTabValue = (value: string) => { + if (value !== "changes" && value !== "all") return + layout.fileTree.setTab(value) + } + + const showAllFiles = () => { + if (fileTreeTab() !== "changes") return + layout.fileTree.setTab("all") + } + + const [store, setStore] = createStore({ + activeDraggable: undefined as string | undefined, + }) + + const handleDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const currentTabs = tabs().all() + const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString()) + if (toIndex === undefined) return + tabs().move(draggable.id.toString(), toIndex) + } + + const handleDragEnd = () => { + setStore("activeDraggable", undefined) + } + + createEffect(() => { + if (!file.ready()) return + + setSessionHandoff(sessionKey(), { + files: tabs() + .all() + .reduce>((acc, tab) => { + const path = file.pathFromTab(tab) + if (!path) return acc + + const selected = file.selectedLines(path) + acc[path] = + selected && typeof selected === "object" && "start" in selected && "end" in selected + ? (selected as SelectedLineRange) + : null + + return acc + }, {}), + }) + }) + + return ( + +
+ + + ) +} diff --git a/Agent-backend/packages/app/src/pages/session/terminal-label.ts b/Agent-backend/packages/app/src/pages/session/terminal-label.ts new file mode 100644 index 000000000..6d336769b --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/terminal-label.ts @@ -0,0 +1,16 @@ +export const terminalTabLabel = (input: { + title?: string + titleNumber?: number + t: (key: string, vars?: Record) => string +}) => { + const title = input.title ?? "" + const number = input.titleNumber ?? 0 + const match = title.match(/^Terminal (\d+)$/) + const parsed = match ? Number(match[1]) : undefined + const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number + + if (title && !isDefaultTitle) return title + if (number > 0) return input.t("terminal.title.numbered", { number }) + if (title) return title + return input.t("terminal.title") +} diff --git a/Agent-backend/packages/app/src/pages/session/terminal-panel.test.ts b/Agent-backend/packages/app/src/pages/session/terminal-panel.test.ts new file mode 100644 index 000000000..43eeec32f --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/terminal-panel.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { terminalTabLabel } from "./terminal-label" + +const t = (key: string, vars?: Record) => { + if (key === "terminal.title.numbered") return `Terminal ${vars?.number}` + if (key === "terminal.title") return "Terminal" + return key +} + +describe("terminalTabLabel", () => { + test("returns custom title unchanged", () => { + const label = terminalTabLabel({ title: "server", titleNumber: 3, t }) + expect(label).toBe("server") + }) + + test("normalizes default numbered title", () => { + const label = terminalTabLabel({ title: "Terminal 2", titleNumber: 2, t }) + expect(label).toBe("Terminal 2") + }) + + test("falls back to generic title", () => { + const label = terminalTabLabel({ title: "", titleNumber: 0, t }) + expect(label).toBe("Terminal") + }) +}) diff --git a/Agent-backend/packages/app/src/pages/session/terminal-panel.tsx b/Agent-backend/packages/app/src/pages/session/terminal-panel.tsx new file mode 100644 index 000000000..8fd652e90 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/terminal-panel.tsx @@ -0,0 +1,287 @@ +import { For, Show, createEffect, createMemo, on } from "solid-js" +import { createStore } from "solid-js/store" +import { createMediaQuery } from "@solid-primitives/media" +import { useParams } from "@solidjs/router" +import { Tabs } from "@opencode-ai/ui/tabs" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" + +import { SortableTerminalTab } from "@/components/session" +import { Terminal } from "@/components/terminal" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { terminalTabLabel } from "@/pages/session/terminal-label" +import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers" +import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" + +export function TerminalPanel() { + const params = useParams() + const layout = useLayout() + const terminal = useTerminal() + const language = useLanguage() + const command = useCommand() + + const isDesktop = createMediaQuery("(min-width: 768px)") + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const view = createMemo(() => layout.view(sessionKey)) + + const opened = createMemo(() => view().terminal.opened()) + const open = createMemo(() => isDesktop() && opened()) + const panel = createPresence(open) + const size = createSizing() + const height = createMemo(() => layout.terminal.height()) + const close = () => view().terminal.close() + let root: HTMLDivElement | undefined + + const [store, setStore] = createStore({ + autoCreated: false, + activeDraggable: undefined as string | undefined, + }) + + createEffect(() => { + if (!opened()) { + setStore("autoCreated", false) + return + } + + if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return + terminal.new() + setStore("autoCreated", true) + }) + + createEffect( + on( + () => terminal.all().length, + (count, prevCount) => { + if (prevCount === undefined || prevCount <= 0 || count !== 0) return + if (!opened()) return + close() + }, + ), + ) + + createEffect( + on( + () => terminal.active(), + (activeId) => { + if (!activeId || !panel.open()) return + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + setTimeout(() => focusTerminalById(activeId), 0) + }, + ), + ) + + createEffect(() => { + if (panel.open()) return + const active = document.activeElement + if (!(active instanceof HTMLElement)) return + if (!root?.contains(active)) return + active.blur() + }) + + createEffect(() => { + const dir = params.dir + if (!dir) return + if (!terminal.ready()) return + language.locale() + + setTerminalHandoff( + dir, + terminal.all().map((pty) => + terminalTabLabel({ + title: pty.title, + titleNumber: pty.titleNumber, + t: language.t as (key: string, vars?: Record) => string, + }), + ), + ) + }) + + const handoff = createMemo(() => { + const dir = params.dir + if (!dir) return [] + return getTerminalHandoff(dir) ?? [] + }) + + const all = createMemo(() => terminal.all()) + const ids = createMemo(() => all().map((pty) => pty.id)) + const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }]))) + + const handleTerminalDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleTerminalDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const terminals = terminal.all() + const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) + if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { + terminal.move(draggable.id.toString(), toIndex) + } + } + + const handleTerminalDragEnd = () => { + setStore("activeDraggable", undefined) + + const activeId = terminal.active() + if (!activeId) return + setTimeout(() => { + focusTerminalById(activeId) + }, 0) + } + + return ( + +
+
+
size.start()}> + { + size.touch() + layout.terminal.resize(next) + }} + onCollapse={close} + /> +
+ +
+ + {(title) => ( +
+ {title} +
+ )} +
+
+
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+
+
+ {language.t("terminal.loading")} +
+
+ } + > + + + +
+ terminal.open(id)} + class="!h-auto !flex-none" + > + + + + {(id) => ( + + {(pty) => } + + )} + + +
+ + + +
+
+
+
+ + {(id) => ( + + {(pty) => ( +
+ terminal.trim(id)} + onCleanup={terminal.update} + onConnectError={() => terminal.clone(id)} + /> +
+ )} +
+ )} +
+
+
+ + + {(draggedId) => ( + + {(t) => ( +
+ {terminalTabLabel({ + title: t().title, + titleNumber: t().titleNumber, + t: language.t as (key: string, vars?: Record) => string, + })} +
+ )} +
+ )} +
+
+
+
+
+
+
+ ) +} diff --git a/Agent-backend/packages/app/src/pages/session/use-session-commands.test.ts b/Agent-backend/packages/app/src/pages/session/use-session-commands.test.ts new file mode 100644 index 000000000..ada1871e1 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/use-session-commands.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { canAddSelectionContext } from "./session-command-helpers" + +describe("canAddSelectionContext", () => { + test("returns false without active tab", () => { + expect( + canAddSelectionContext({ + active: undefined, + pathFromTab: () => "src/a.ts", + selectedLines: () => ({ start: 1, end: 1 }), + }), + ).toBe(false) + }) + + test("returns false when active tab is not a file", () => { + expect( + canAddSelectionContext({ + active: "context", + pathFromTab: () => undefined, + selectedLines: () => ({ start: 1, end: 1 }), + }), + ).toBe(false) + }) + + test("returns false without selected lines", () => { + expect( + canAddSelectionContext({ + active: "file://src/a.ts", + pathFromTab: () => "src/a.ts", + selectedLines: () => null, + }), + ).toBe(false) + }) + + test("returns true when file and selection exist", () => { + expect( + canAddSelectionContext({ + active: "file://src/a.ts", + pathFromTab: () => "src/a.ts", + selectedLines: () => ({ start: 1, end: 2 }), + }), + ).toBe(true) + }) +}) diff --git a/Agent-backend/packages/app/src/pages/session/use-session-commands.tsx b/Agent-backend/packages/app/src/pages/session/use-session-commands.tsx new file mode 100644 index 000000000..b8ddeda82 --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/use-session-commands.tsx @@ -0,0 +1,505 @@ +import { createMemo } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { useCommand, type CommandOption } from "@/context/command" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" +import { usePermission } from "@/context/permission" +import { usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useTerminal } from "@/context/terminal" +import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" +import { DialogFork } from "@/components/dialog-fork" +import { showToast } from "@opencode-ai/ui/toast" +import { findLast } from "@opencode-ai/util/array" +import { extractPromptFromParts } from "@/utils/prompt" +import { UserMessage } from "@opencode-ai/sdk/v2" +import { canAddSelectionContext } from "@/pages/session/session-command-helpers" + +export type SessionCommandContext = { + navigateMessageByOffset: (offset: number) => void + setActiveMessage: (message: UserMessage | undefined) => void + focusInput: () => void +} + +const withCategory = (category: string) => { + return (option: Omit): CommandOption => ({ + ...option, + category, + }) +} + +export const useSessionCommands = (actions: SessionCommandContext) => { + const command = useCommand() + const dialog = useDialog() + const file = useFile() + const language = useLanguage() + const local = useLocal() + const permission = usePermission() + const prompt = usePrompt() + const sdk = useSDK() + const sync = useSync() + const terminal = useTerminal() + const layout = useLayout() + const params = useParams() + const navigate = useNavigate() + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + + const idle = { type: "idle" as const } + const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle) + const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[]) + const visibleUserMessages = createMemo(() => { + const revert = info()?.revert?.messageID + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }) + + const showAllFiles = () => { + if (layout.fileTree.tab() !== "changes") return + layout.fileTree.setTab("all") + } + + const selectionPreview = (path: string, selection: FileSelection) => { + const content = file.get(path)?.content?.content + if (!content) return undefined + const start = Math.max(1, Math.min(selection.startLine, selection.endLine)) + const end = Math.max(selection.startLine, selection.endLine) + const lines = content.split("\n").slice(start - 1, end) + if (lines.length === 0) return undefined + return lines.slice(0, 2).join("\n") + } + + const addSelectionToContext = (path: string, selection: FileSelection) => { + const preview = selectionPreview(path, selection) + prompt.context.add({ type: "file", path, selection, preview }) + } + + const navigateMessageByOffset = actions.navigateMessageByOffset + const setActiveMessage = actions.setActiveMessage + const focusInput = actions.focusInput + + const sessionCommand = withCategory(language.t("command.category.session")) + const fileCommand = withCategory(language.t("command.category.file")) + const contextCommand = withCategory(language.t("command.category.context")) + const viewCommand = withCategory(language.t("command.category.view")) + const terminalCommand = withCategory(language.t("command.category.terminal")) + const modelCommand = withCategory(language.t("command.category.model")) + const mcpCommand = withCategory(language.t("command.category.mcp")) + const agentCommand = withCategory(language.t("command.category.agent")) + const permissionsCommand = withCategory(language.t("command.category.permissions")) + + const sessionCommands = createMemo(() => [ + sessionCommand({ + id: "session.new", + title: language.t("command.session.new"), + keybind: "mod+shift+s", + slash: "new", + onSelect: () => navigate(`/${params.dir}/session`), + }), + ]) + + const fileCommands = createMemo(() => [ + fileCommand({ + id: "file.open", + title: language.t("command.file.open"), + description: language.t("palette.search.placeholder"), + keybind: "mod+p", + slash: "open", + onSelect: () => dialog.show(() => ), + }), + fileCommand({ + id: "tab.close", + title: language.t("command.tab.close"), + keybind: "mod+w", + disabled: !tabs().active(), + onSelect: () => { + const active = tabs().active() + if (!active) return + tabs().close(active) + }, + }), + ]) + + const contextCommands = createMemo(() => [ + contextCommand({ + id: "context.addSelection", + title: language.t("command.context.addSelection"), + description: language.t("command.context.addSelection.description"), + keybind: "mod+shift+l", + disabled: !canAddSelectionContext({ + active: tabs().active(), + pathFromTab: file.pathFromTab, + selectedLines: file.selectedLines, + }), + onSelect: () => { + const active = tabs().active() + if (!active) return + const path = file.pathFromTab(active) + if (!path) return + + const range = file.selectedLines(path) as SelectedLineRange | null | undefined + if (!range) { + showToast({ + title: language.t("toast.context.noLineSelection.title"), + description: language.t("toast.context.noLineSelection.description"), + }) + return + } + + addSelectionToContext(path, selectionFromLines(range)) + }, + }), + ]) + + const viewCommands = createMemo(() => [ + viewCommand({ + id: "terminal.toggle", + title: language.t("command.terminal.toggle"), + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => view().terminal.toggle(), + }), + viewCommand({ + id: "review.toggle", + title: language.t("command.review.toggle"), + keybind: "mod+shift+r", + onSelect: () => view().reviewPanel.toggle(), + }), + viewCommand({ + id: "fileTree.toggle", + title: language.t("command.fileTree.toggle"), + keybind: "mod+\\", + onSelect: () => layout.fileTree.toggle(), + }), + viewCommand({ + id: "input.focus", + title: language.t("command.input.focus"), + keybind: "ctrl+l", + onSelect: () => focusInput(), + }), + terminalCommand({ + id: "terminal.new", + title: language.t("command.terminal.new"), + description: language.t("command.terminal.new.description"), + keybind: "ctrl+alt+t", + onSelect: () => { + if (terminal.all().length > 0) terminal.new() + view().terminal.open() + }, + }), + ]) + + const messageCommands = createMemo(() => [ + sessionCommand({ + id: "message.previous", + title: language.t("command.message.previous"), + description: language.t("command.message.previous.description"), + keybind: "mod+arrowup", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(-1), + }), + sessionCommand({ + id: "message.next", + title: language.t("command.message.next"), + description: language.t("command.message.next.description"), + keybind: "mod+arrowdown", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(1), + }), + ]) + + const agentCommands = createMemo(() => [ + modelCommand({ + id: "model.choose", + title: language.t("command.model.choose"), + description: language.t("command.model.choose.description"), + keybind: "mod+'", + slash: "model", + onSelect: () => dialog.show(() => ), + }), + mcpCommand({ + id: "mcp.toggle", + title: language.t("command.mcp.toggle"), + description: language.t("command.mcp.toggle.description"), + keybind: "mod+;", + slash: "mcp", + onSelect: () => dialog.show(() => ), + }), + agentCommand({ + id: "agent.cycle", + title: language.t("command.agent.cycle"), + description: language.t("command.agent.cycle.description"), + keybind: "mod+.", + slash: "agent", + onSelect: () => local.agent.move(1), + }), + agentCommand({ + id: "agent.cycle.reverse", + title: language.t("command.agent.cycle.reverse"), + description: language.t("command.agent.cycle.reverse.description"), + keybind: "shift+mod+.", + onSelect: () => local.agent.move(-1), + }), + modelCommand({ + id: "model.variant.cycle", + title: language.t("command.model.variant.cycle"), + description: language.t("command.model.variant.cycle.description"), + keybind: "shift+mod+d", + onSelect: () => { + local.model.variant.cycle() + }, + }), + ]) + + const isAutoAcceptActive = () => { + const sessionID = params.id + if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) + return permission.isAutoAcceptingDirectory(sdk.directory) + } + + const permissionCommands = createMemo(() => [ + permissionsCommand({ + id: "permissions.autoaccept", + title: isAutoAcceptActive() + ? language.t("command.permissions.autoaccept.disable") + : language.t("command.permissions.autoaccept.enable"), + keybind: "mod+shift+a", + disabled: false, + onSelect: () => { + const sessionID = params.id + if (sessionID) { + permission.toggleAutoAccept(sessionID, sdk.directory) + } else { + permission.toggleAutoAcceptDirectory(sdk.directory) + } + const active = sessionID + ? permission.isAutoAccepting(sessionID, sdk.directory) + : permission.isAutoAcceptingDirectory(sdk.directory) + showToast({ + title: active + ? language.t("toast.permissions.autoaccept.on.title") + : language.t("toast.permissions.autoaccept.off.title"), + description: active + ? language.t("toast.permissions.autoaccept.on.description") + : language.t("toast.permissions.autoaccept.off.description"), + }) + }, + }), + ]) + + const sessionActionCommands = createMemo(() => [ + sessionCommand({ + id: "session.undo", + title: language.t("command.session.undo"), + description: language.t("command.session.undo.description"), + slash: "undo", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + if (status()?.type !== "idle") { + await sdk.client.session.abort({ sessionID }).catch(() => {}) + } + const revert = info()?.revert?.messageID + const message = findLast(userMessages(), (x) => !revert || x.id < revert) + if (!message) return + await sdk.client.session.revert({ sessionID, messageID: message.id }) + const parts = sync.data.part[message.id] + if (parts) { + const restored = extractPromptFromParts(parts, { directory: sdk.directory }) + prompt.set(restored) + } + const priorMessage = findLast(userMessages(), (x) => x.id < message.id) + setActiveMessage(priorMessage) + }, + }), + sessionCommand({ + id: "session.redo", + title: language.t("command.session.redo"), + description: language.t("command.session.redo.description"), + slash: "redo", + disabled: !params.id || !info()?.revert?.messageID, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + const revertMessageID = info()?.revert?.messageID + if (!revertMessageID) return + const nextMessage = userMessages().find((x) => x.id > revertMessageID) + if (!nextMessage) { + await sdk.client.session.unrevert({ sessionID }) + prompt.reset() + const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) + setActiveMessage(lastMsg) + return + } + await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) + setActiveMessage(priorMsg) + }, + }), + sessionCommand({ + id: "session.compact", + title: language.t("command.session.compact"), + description: language.t("command.session.compact.description"), + slash: "compact", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + const model = local.model.current() + if (!model) { + showToast({ + title: language.t("toast.model.none.title"), + description: language.t("toast.model.none.description"), + }) + return + } + await sdk.client.session.summarize({ + sessionID, + modelID: model.id, + providerID: model.provider.id, + }) + }, + }), + sessionCommand({ + id: "session.fork", + title: language.t("command.session.fork"), + description: language.t("command.session.fork.description"), + slash: "fork", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: () => dialog.show(() => ), + }), + ]) + + const shareCommands = createMemo(() => { + if (sync.data.config.share === "disabled") return [] + return [ + sessionCommand({ + id: "session.share", + title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"), + description: info()?.share?.url + ? language.t("toast.session.share.success.description") + : language.t("command.session.share.description"), + slash: "share", + disabled: !params.id, + onSelect: async () => { + if (!params.id) return + + const write = (value: string) => { + const body = typeof document === "undefined" ? undefined : document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = value + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return Promise.resolve(true) + } + + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return Promise.resolve(false) + return clipboard.writeText(value).then( + () => true, + () => false, + ) + } + + const copy = async (url: string, existing: boolean) => { + const ok = await write(url) + if (!ok) { + showToast({ + title: language.t("toast.session.share.copyFailed.title"), + variant: "error", + }) + return + } + + showToast({ + title: existing + ? language.t("session.share.copy.copied") + : language.t("toast.session.share.success.title"), + description: language.t("toast.session.share.success.description"), + variant: "success", + }) + } + + const existing = info()?.share?.url + if (existing) { + await copy(existing, true) + return + } + + const url = await sdk.client.session + .share({ sessionID: params.id }) + .then((res) => res.data?.share?.url) + .catch(() => undefined) + if (!url) { + showToast({ + title: language.t("toast.session.share.failed.title"), + description: language.t("toast.session.share.failed.description"), + variant: "error", + }) + return + } + + await copy(url, false) + }, + }), + sessionCommand({ + id: "session.unshare", + title: language.t("command.session.unshare"), + description: language.t("command.session.unshare.description"), + slash: "unshare", + disabled: !params.id || !info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .unshare({ sessionID: params.id }) + .then(() => + showToast({ + title: language.t("toast.session.unshare.success.title"), + description: language.t("toast.session.unshare.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: language.t("toast.session.unshare.failed.title"), + description: language.t("toast.session.unshare.failed.description"), + variant: "error", + }), + ) + }, + }), + ] + }) + + command.register("session", () => + [ + sessionCommands(), + fileCommands(), + contextCommands(), + viewCommands(), + messageCommands(), + agentCommands(), + permissionCommands(), + sessionActionCommands(), + shareCommands(), + ].flatMap((x) => x), + ) +} diff --git a/Agent-backend/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/Agent-backend/packages/app/src/pages/session/use-session-hash-scroll.test.ts new file mode 100644 index 000000000..7f3389baa --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/use-session-hash-scroll.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { messageIdFromHash } from "./message-id-from-hash" + +describe("messageIdFromHash", () => { + test("parses hash with leading #", () => { + expect(messageIdFromHash("#message-abc123")).toBe("abc123") + }) + + test("parses raw hash fragment", () => { + expect(messageIdFromHash("message-42")).toBe("42") + }) + + test("ignores non-message anchors", () => { + expect(messageIdFromHash("#review-panel")).toBeUndefined() + }) +}) diff --git a/Agent-backend/packages/app/src/pages/session/use-session-hash-scroll.ts b/Agent-backend/packages/app/src/pages/session/use-session-hash-scroll.ts new file mode 100644 index 000000000..1ea6a302b --- /dev/null +++ b/Agent-backend/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -0,0 +1,199 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { useLocation, useNavigate } from "@solidjs/router" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" +import { messageIdFromHash } from "./message-id-from-hash" + +export { messageIdFromHash } from "./message-id-from-hash" + +export const useSessionHashScroll = (input: { + sessionKey: () => string + sessionID: () => string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + turnStart: () => number + currentMessageId: () => string | undefined + pendingMessage: () => string | undefined + setPendingMessage: (value: string | undefined) => void + setActiveMessage: (message: UserMessage | undefined) => void + setTurnStart: (value: number) => void + autoScroll: { pause: () => void; forceScrollToBottom: () => void } + scroller: () => HTMLDivElement | undefined + anchor: (id: string) => string + scheduleScrollState: (el: HTMLDivElement) => void + consumePendingMessage: (key: string) => string | undefined +}) => { + const visibleUserMessages = createMemo(() => input.visibleUserMessages()) + const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) + const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) + let pendingKey = "" + let clearing = false + + const location = useLocation() + const navigate = useNavigate() + + const frames = new Set() + const queue = (fn: () => void) => { + const id = requestAnimationFrame(() => { + frames.delete(id) + fn() + }) + frames.add(id) + } + const cancel = () => { + for (const id of frames) cancelAnimationFrame(id) + frames.clear() + } + + const clearMessageHash = () => { + cancel() + input.consumePendingMessage(input.sessionKey()) + if (input.pendingMessage()) input.setPendingMessage(undefined) + if (!location.hash) return + clearing = true + navigate(location.pathname + location.search, { replace: true }) + } + + const updateHash = (id: string) => { + const hash = `#${input.anchor(id)}` + if (location.hash === hash) return + clearing = false + navigate(location.pathname + location.search + hash, { + replace: true, + }) + } + + const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { + const root = input.scroller() + if (!root) return false + + const a = el.getBoundingClientRect() + const b = root.getBoundingClientRect() + const sticky = root.querySelector("[data-session-title]") + const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 + const top = Math.max(0, a.top - b.top + root.scrollTop - inset) + root.scrollTo({ top, behavior }) + return true + } + + const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => { + const el = document.getElementById(input.anchor(id)) + if (el) return scrollToElement(el, behavior) + if (left <= 0) return false + queue(() => { + seek(id, behavior, left - 1) + }) + return false + } + + const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { + cancel() + if (input.currentMessageId() !== message.id) input.setActiveMessage(message) + + const index = messageIndex().get(message.id) ?? -1 + if (index !== -1 && index < input.turnStart()) { + input.setTurnStart(index) + + queue(() => { + seek(message.id, behavior) + }) + + updateHash(message.id) + return + } + + if (seek(message.id, behavior)) { + updateHash(message.id) + return + } + + updateHash(message.id) + } + + const applyHash = (behavior: ScrollBehavior) => { + const hash = location.hash.slice(1) + if (!hash) { + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + return + } + + const messageId = messageIdFromHash(hash) + if (messageId) { + input.autoScroll.pause() + const msg = messageById().get(messageId) + if (msg) { + scrollToMessage(msg, behavior) + return + } + return + } + + const target = document.getElementById(hash) + if (target) { + input.autoScroll.pause() + scrollToElement(target, behavior) + return + } + + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + } + + createEffect(() => { + const hash = location.hash + if (!hash) clearing = false + if (!input.sessionID() || !input.messagesReady()) return + cancel() + queue(() => applyHash("auto")) + }) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + + visibleUserMessages() + input.turnStart() + + let targetId = input.pendingMessage() + if (!targetId) { + const key = input.sessionKey() + if (pendingKey !== key) { + pendingKey = key + const next = input.consumePendingMessage(key) + if (next) { + input.setPendingMessage(next) + targetId = next + } + } + } + + if (!targetId && !clearing) targetId = messageIdFromHash(location.hash) + if (!targetId) return + + const pending = input.pendingMessage() === targetId + const msg = messageById().get(targetId) + if (!msg) return + + if (pending) input.setPendingMessage(undefined) + if (input.currentMessageId() === targetId && !pending) return + + input.autoScroll.pause() + cancel() + queue(() => scrollToMessage(msg, "auto")) + }) + + onMount(() => { + if (typeof window !== "undefined" && "scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual" + } + }) + + onCleanup(cancel) + + return { + clearMessageHash, + scrollToMessage, + applyHash, + } +} diff --git a/Agent-backend/packages/app/src/theme-preload.test.ts b/Agent-backend/packages/app/src/theme-preload.test.ts new file mode 100644 index 000000000..00d7da239 --- /dev/null +++ b/Agent-backend/packages/app/src/theme-preload.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, test } from "bun:test" + +const src = await Bun.file(new URL("../public/oc-theme-preload.js", import.meta.url)).text() + +const run = () => Function(src)() + +beforeEach(() => { + document.head.innerHTML = "" + document.documentElement.removeAttribute("data-theme") + document.documentElement.removeAttribute("data-color-scheme") + localStorage.clear() + Object.defineProperty(window, "matchMedia", { + value: () => + ({ + matches: false, + }) as MediaQueryList, + configurable: true, + }) +}) + +describe("theme preload", () => { + test("migrates legacy oc-1 to oc-2 before mount", () => { + localStorage.setItem("opencode-theme-id", "oc-1") + localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;") + localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;") + + run() + + expect(document.documentElement.dataset.theme).toBe("oc-2") + expect(document.documentElement.dataset.colorScheme).toBe("light") + expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2") + expect(localStorage.getItem("opencode-theme-css-light")).toBeNull() + expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull() + expect(document.getElementById("oc-theme-preload")).toBeNull() + }) + + test("keeps cached css for non-default themes", () => { + localStorage.setItem("opencode-theme-id", "nightowl") + localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;") + + run() + + expect(document.documentElement.dataset.theme).toBe("nightowl") + expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#fff;") + }) +}) diff --git a/Agent-backend/packages/app/src/utils/CLAUDE.md b/Agent-backend/packages/app/src/utils/CLAUDE.md new file mode 100644 index 000000000..9191c27a0 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/CLAUDE.md @@ -0,0 +1,158 @@ +# utils + +> **Path:** `Agent-backend/packages/app/src/utils/` + +## Purpose + +Shared utility functions for the OpenCode web app. Framework-agnostic (mostly pure TypeScript) except for `solid-dnd.tsx` and `persist.ts` which depend on SolidJS primitives. Covers persistence, server communication, terminal I/O, sound playback, speech, DOM helpers, caching, error formatting, and more. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Orbit Desktop (SDK) | `reference` | Several utilities are directly reusable or adaptable: `server-health.ts` (health polling pattern), `server-errors.ts` (error formatting), `terminal-writer.ts` (write batching), `scoped-cache.ts` (LRU cache), `worktree.ts` (worktree state machine). The `persist.ts` pattern maps to Orbit's Zustand `persist` middleware. | +| Orbit CLI | `reference` | Same | + +## Recommendation + +**KEEP AS REFERENCE** — Most portable directory in `packages/app/`. 30 out of 32 files are pure TypeScript with no SolidJS dependency. Several utilities (terminal-writer, server-health, scoped-cache, worktree state machine, aim-aware hover) are directly copy-pasteable into Orbit's React codebase. + +## When to Reference This + +| If you're building... | Read this file | Portable? | +| ------------------------------------------------------------- | ----------------------- | ------------------------------------------------ | +| **Server health polling** with retry and timeout | `server-health.ts` | **Yes** — pure async | +| **Server error formatting** (config invalid, model not found) | `server-errors.ts` | **Yes** — pure logic | +| **SDK client factory** for a server URL | `server.ts` | **Yes** — thin wrapper | +| **Terminal write batching** to prevent interleaving | `terminal-writer.ts` | **Yes** — pure async, uses `queueMicrotask` | +| **LRU cache** with TTL and dispose callbacks | `scoped-cache.ts` | **Yes** — generic, no framework | +| **Worktree state machine** (pending → ready → failed) | `worktree.ts` | **Yes** — deferred promise pattern | +| **Aim-aware hover** for submenus (Amazon mega-menu algorithm) | `aim.ts` | **Yes** — pure DOM/math | +| **Sound effects** (46 AAC clips in 5 categories) | `sound.ts` | Needs asset URL adaptation | +| **Speech-to-text** (browser SpeechRecognition API) | `speech.ts` | **Yes** — browser API | +| **localStorage persistence** with LRU caching | `persist.ts` | SolidJS-specific (use Zustand `persist` instead) | +| **Deep equality check** | `same.ts` | **Yes** — JSON comparison | +| **UUID generation** | `uuid.ts` | **Yes** — `crypto.randomUUID()` wrapper | +| **Base64 decode** | `base64.ts` | **Yes** — `atob` wrapper | +| **Time formatting** (relative, duration) | `time.ts` | **Yes** — pure formatting | +| **Notification click routing** | `notification-click.ts` | Pattern reusable | +| **Drag-and-drop helpers** | `solid-dnd.tsx` | SolidJS-specific (use `@dnd-kit` instead) | +| **Runtime capability detection** (optional APIs) | `runtime-adapters.ts` | **Yes** — safe wrappers | +| **Comment/note parsing** for code review | `comment-note.ts` | **Yes** — pure parsing | + +## File Inventory + +### Persistence & Storage (2 files) + +| File | Purpose | Tests | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------- | +| `persist.ts` | localStorage wrapper with LRU caching (500 entries, 8MB limit). `Persist` namespace defines standard keys (`GLOBAL_STORAGE = "opencode.global.dat"`, `LOCAL_PREFIX = "opencode."`). `persisted()` wraps `@solid-primitives/storage`'s `makePersisted()` with cache and legacy migration support. | `persist.test.ts` | +| `scoped-cache.ts` | Generic LRU cache factory — `createScopedCache()` with `maxEntries`, `ttlMs`, and `dispose` callback. Used for caching per-key computed values. | `scoped-cache.test.ts` | + +### Server Communication (3 files) + +| File | Purpose | Tests | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| `server-health.ts` | Server health checking — `checkServerHealth()` with configurable timeout (3s default), retry (2 retries, 100ms delay), and abort signal support. Returns `{ healthy, version }`. | `server-health.test.ts` | +| `server-errors.ts` | Server error formatting — `formatServerError()` handles `ConfigInvalidError` and `ProviderModelNotFoundError` with i18n-aware messages. Parses Zod validation issues. | `server-errors.test.ts` | +| `server.ts` | SDK client factory — `createSdkForServer()` creates an `OpencodeClient` for a given server connection URL. | | + +### Terminal & I/O (2 files) + +| File | Purpose | Tests | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | +| `terminal-writer.ts` | Write batching for terminal output — coalesces multiple `write()` calls into batched flushes using `queueMicrotask`. Prevents write interleaving and supports `waitForFlush()`. | `terminal-writer.test.ts` | +| `runtime-adapters.ts` | Runtime capability detection — `isDisposable()`, `disposeIfDisposable()`, `setOptionIfSupported()`, `getHoveredLinkText()`. Safe wrappers for optional APIs (Ghostty terminal extensions). | `runtime-adapters.test.ts` | + +### Audio & Speech (2 files) + +| File | Purpose | Tests | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| `sound.ts` | Sound playback — 46 AAC sound effects in 5 categories (alert, bip-bop, staplebops, nope, yup). `playSound(src)` creates `Audio` element, returns cleanup function. `soundSrc(id)` maps sound ID to URL. | | +| `speech.ts` | Speech-to-text — browser `SpeechRecognition` API wrapper for voice input. | | + +### DOM & UI (3 files) + +| File | Purpose | Tests | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| `dom.ts` | DOM utilities — re-exported via `index.ts`. | | +| `aim.ts` | Aim-aware hover — `createAim()` implements the "aim triangle" algorithm (like Amazon mega-menus) to prevent accidental hover exits when moving cursor toward a submenu. Uses mouse position history. | | +| `solid-dnd.tsx` | `@thisbeyond/solid-dnd` helpers — drag-and-drop collision detection wrappers for SolidJS. | | + +### Data & Encoding (3 files) + +| File | Purpose | Tests | +| ----------- | ------------------------------------------------------------------------------------ | -------------- | +| `base64.ts` | Base64 decode — `decode64()` wrapper around `atob` with error handling. | | +| `uuid.ts` | UUID generation — `createUUID()` wrapper around `crypto.randomUUID()` with fallback. | `uuid.test.ts` | +| `same.ts` | Deep equality — `same()` for comparing objects by JSON serialization. | | + +### Time & Formatting (1 file) + +| File | Purpose | +| --------- | ----------------------------------------------------------------------- | +| `time.ts` | Time formatting utilities — relative time display, duration formatting. | + +### Notifications (1 file) + +| File | Purpose | Tests | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | +| `notification-click.ts` | Notification click routing — `setNavigate()` stores router navigate function, notification click handlers route to the correct session. | `notification-click.test.ts` | + +### State Management (2 files) + +| File | Purpose | Tests | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `worktree.ts` | Worktree state machine — tracks worktree creation status (`pending` → `ready` → `failed`) per directory. Deferred promise pattern for async coordination. | `worktree.test.ts` | +| `agent.ts` | Agent-related utilities. | | + +### Other (2 files) + +| File | Purpose | +| ----------------- | ------------------------------------------------------------------------------------------------- | +| `comment-note.ts` | Comment/note parsing — `parseCommentNote()`, `readCommentMetadata()` for code review annotations. | +| `prompt.ts` | Prompt-related utilities. | +| `index.ts` | Barrel — re-exports `dom.ts`. | + +## Test Coverage + +| Test File | Covers | +| ---------------------------- | ---------------------------------------------------- | +| `persist.test.ts` | localStorage wrapper, cache limits, legacy migration | +| `scoped-cache.test.ts` | LRU eviction, TTL expiry, dispose callbacks | +| `server-health.test.ts` | Health check polling, timeout, retry | +| `server-errors.test.ts` | Error formatting, config invalid, model not found | +| `terminal-writer.test.ts` | Write batching, flush ordering | +| `runtime-adapters.test.ts` | Capability detection, safe wrappers | +| `notification-click.test.ts` | Navigate function storage and routing | +| `uuid.test.ts` | UUID generation | +| `worktree.test.ts` | State machine transitions | + +## Dependencies + +- **SolidJS:** `solid-js` (only in `persist.ts` and `solid-dnd.tsx`) +- **Internal:** `@/context/platform`, `@/context/server` (for server utils) +- **UI library:** `@opencode-ai/ui/audio/*` (46 AAC sound files), `@opencode-ai/ui/theme` +- **Utilities:** `@opencode-ai/util/encode` (`checksum`), `@solid-primitives/storage` (`makePersisted`) +- **External:** `@thisbeyond/solid-dnd` (drag helpers) + +## Development Guide + +For Orbit, these utilities are most directly reusable: + +1. **`terminal-writer.ts`** — write batching pattern is critical for terminal performance. Orbit's terminal uses the same coalescing approach to prevent write interleaving. + +2. **`server-health.ts`** — health polling with retry and timeout. The pattern maps to Orbit's agent-bridge health checking. + +3. **`scoped-cache.ts`** — generic LRU cache with TTL and dispose. Useful for any keyed resource caching (file contents, syntax highlights, etc.). + +4. **`worktree.ts`** — deferred promise state machine. Clean pattern for tracking async multi-step operations. + +5. **`aim.ts`** — the Amazon mega-menu algorithm for hover-stable submenus. Reusable for any popover/dropdown that needs aim-aware hover. + +## Notes + +- **`sound.ts` imports 46 AAC files** — all from `@opencode-ai/ui/audio/`. These are Vite asset imports (resolved to URLs at build time). Categories: alert (10), bip-bop (10), staplebops (7), nope (12), yup (6). Each has a string ID for settings persistence. +- **`persist.ts` LRU cache** is localStorage-level, not application-level. It caches raw `getItem()` values (max 500 entries, 8MB) to avoid repeated localStorage reads. Separate from `scoped-cache.ts` which is a general-purpose data cache. +- **`index.ts` is minimal** — only re-exports `dom.ts`. Most utils are imported directly by path. +- **9 test files** — most utilities have accompanying tests. Tests use HappyDOM (via `happydom.ts` preload). diff --git a/Agent-backend/packages/app/src/utils/agent.ts b/Agent-backend/packages/app/src/utils/agent.ts new file mode 100644 index 000000000..7c2c81e74 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/agent.ts @@ -0,0 +1,11 @@ +const defaults: Record = { + ask: "var(--icon-agent-ask-base)", + build: "var(--icon-agent-build-base)", + docs: "var(--icon-agent-docs-base)", + plan: "var(--icon-agent-plan-base)", +} + +export function agentColor(name: string, custom?: string) { + if (custom) return custom + return defaults[name] ?? defaults[name.toLowerCase()] +} diff --git a/Agent-backend/packages/app/src/utils/aim.ts b/Agent-backend/packages/app/src/utils/aim.ts new file mode 100644 index 000000000..23471959e --- /dev/null +++ b/Agent-backend/packages/app/src/utils/aim.ts @@ -0,0 +1,138 @@ +type Point = { x: number; y: number } + +export function createAim(props: { + enabled: () => boolean + active: () => string | undefined + el: () => HTMLElement | undefined + onActivate: (id: string) => void + delay?: number + max?: number + tolerance?: number + edge?: number +}) { + const state = { + locs: [] as Point[], + timer: undefined as number | undefined, + pending: undefined as string | undefined, + over: undefined as string | undefined, + last: undefined as Point | undefined, + } + + const delay = props.delay ?? 250 + const max = props.max ?? 4 + const tolerance = props.tolerance ?? 80 + const edge = props.edge ?? 18 + + const cancel = () => { + if (state.timer !== undefined) clearTimeout(state.timer) + state.timer = undefined + state.pending = undefined + } + + const reset = () => { + cancel() + state.over = undefined + state.last = undefined + state.locs.length = 0 + } + + const move = (event: MouseEvent) => { + if (!props.enabled()) return + const el = props.el() + if (!el) return + + const rect = el.getBoundingClientRect() + const x = event.clientX + const y = event.clientY + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return + + state.locs.push({ x, y }) + if (state.locs.length > max) state.locs.shift() + } + + const wait = () => { + if (!props.enabled()) return 0 + if (!props.active()) return 0 + + const el = props.el() + if (!el) return 0 + if (state.locs.length < 2) return 0 + + const rect = el.getBoundingClientRect() + const loc = state.locs[state.locs.length - 1] + if (!loc) return 0 + + const prev = state.locs[0] ?? loc + if (prev.x < rect.left || prev.x > rect.right || prev.y < rect.top || prev.y > rect.bottom) return 0 + if (state.last && loc.x === state.last.x && loc.y === state.last.y) return 0 + + if (rect.right - loc.x <= edge) { + state.last = loc + return delay + } + + const upper = { x: rect.right, y: rect.top - tolerance } + const lower = { x: rect.right, y: rect.bottom + tolerance } + const slope = (a: Point, b: Point) => (b.y - a.y) / (b.x - a.x) + + const decreasing = slope(loc, upper) + const increasing = slope(loc, lower) + const prevDecreasing = slope(prev, upper) + const prevIncreasing = slope(prev, lower) + + if (decreasing < prevDecreasing && increasing > prevIncreasing) { + state.last = loc + return delay + } + + state.last = undefined + return 0 + } + + const activate = (id: string) => { + cancel() + props.onActivate(id) + } + + const request = (id: string) => { + if (!id) return + if (props.active() === id) return + + if (!props.active()) { + activate(id) + return + } + + const ms = wait() + if (ms === 0) { + activate(id) + return + } + + cancel() + state.pending = id + state.timer = window.setTimeout(() => { + state.timer = undefined + if (state.pending !== id) return + state.pending = undefined + if (!props.enabled()) return + if (!props.active()) return + if (state.over !== id) return + props.onActivate(id) + }, ms) + } + + const enter = (id: string, event: MouseEvent) => { + if (!props.enabled()) return + state.over = id + move(event) + request(id) + } + + const leave = (id: string) => { + if (state.over === id) state.over = undefined + if (state.pending === id) cancel() + } + + return { move, enter, leave, activate, request, cancel, reset } +} diff --git a/Agent-backend/packages/app/src/utils/base64.ts b/Agent-backend/packages/app/src/utils/base64.ts new file mode 100644 index 000000000..c1f9d88c6 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/base64.ts @@ -0,0 +1,10 @@ +import { base64Decode } from "@opencode-ai/util/encode" + +export function decode64(value: string | undefined) { + if (value === undefined) return + try { + return base64Decode(value) + } catch { + return + } +} diff --git a/Agent-backend/packages/app/src/utils/comment-note.ts b/Agent-backend/packages/app/src/utils/comment-note.ts new file mode 100644 index 000000000..99e87fc81 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/comment-note.ts @@ -0,0 +1,88 @@ +import type { FileSelection } from "@/context/file" + +export type PromptComment = { + path: string + selection?: FileSelection + comment: string + preview?: string + origin?: "review" | "file" +} + +function selection(selection: unknown) { + if (!selection || typeof selection !== "object") return undefined + const startLine = Number((selection as FileSelection).startLine) + const startChar = Number((selection as FileSelection).startChar) + const endLine = Number((selection as FileSelection).endLine) + const endChar = Number((selection as FileSelection).endChar) + if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined + return { + startLine, + startChar, + endLine, + endChar, + } satisfies FileSelection +} + +export function createCommentMetadata(input: PromptComment) { + return { + opencodeComment: { + path: input.path, + selection: input.selection, + comment: input.comment, + preview: input.preview, + origin: input.origin, + }, + } +} + +export function readCommentMetadata(value: unknown) { + if (!value || typeof value !== "object") return + const meta = (value as { opencodeComment?: unknown }).opencodeComment + if (!meta || typeof meta !== "object") return + const path = (meta as { path?: unknown }).path + const comment = (meta as { comment?: unknown }).comment + if (typeof path !== "string" || typeof comment !== "string") return + const preview = (meta as { preview?: unknown }).preview + const origin = (meta as { origin?: unknown }).origin + return { + path, + selection: selection((meta as { selection?: unknown }).selection), + comment, + preview: typeof preview === "string" ? preview : undefined, + origin: origin === "review" || origin === "file" ? origin : undefined, + } satisfies PromptComment +} + +export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) { + const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined + const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined + const range = + start === undefined || end === undefined + ? "this file" + : start === end + ? `line ${start}` + : `lines ${start} through ${end}` + return `The user made the following comment regarding ${range} of ${input.path}: ${input.comment}` +} + +export function parseCommentNote(text: string) { + const match = text.match( + /^The user made the following comment regarding (this file|line (\d+)|lines (\d+) through (\d+)) of (.+?): ([\s\S]+)$/, + ) + if (!match) return + const start = match[2] ? Number(match[2]) : match[3] ? Number(match[3]) : undefined + const end = match[2] ? Number(match[2]) : match[4] ? Number(match[4]) : undefined + return { + path: match[5], + selection: + start !== undefined && end !== undefined + ? { + startLine: start, + startChar: 0, + endLine: end, + endChar: 0, + } + : undefined, + comment: match[6], + } satisfies PromptComment +} diff --git a/Agent-backend/packages/app/src/utils/dom.ts b/Agent-backend/packages/app/src/utils/dom.ts new file mode 100644 index 000000000..4f3724c7c --- /dev/null +++ b/Agent-backend/packages/app/src/utils/dom.ts @@ -0,0 +1,51 @@ +export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number { + const r = document.createRange() + r.selectNodeContents(lineElement) + r.setEnd(targetNode, offset) + return r.toString().length +} + +export function getNodeOffsetInLine(lineElement: Element, charIndex: number): { node: Node; offset: number } | null { + const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null) + let remaining = Math.max(0, charIndex) + let lastText: Node | null = null + let lastLen = 0 + let node: Node | null + while ((node = walker.nextNode())) { + const len = node.textContent?.length || 0 + lastText = node + lastLen = len + if (remaining <= len) return { node, offset: remaining } + remaining -= len + } + if (lastText) return { node: lastText, offset: lastLen } + if (lineElement.firstChild) return { node: lineElement.firstChild, offset: 0 } + return null +} + +export function getSelectionInContainer( + container: HTMLElement, +): { sl: number; sch: number; el: number; ech: number } | null { + const s = window.getSelection() + if (!s || s.rangeCount === 0) return null + const r = s.getRangeAt(0) + const sc = r.startContainer + const ec = r.endContainer + const getLineElement = (n: Node) => + (n.nodeType === Node.TEXT_NODE ? (n.parentElement as Element) : (n as Element))?.closest(".line") + const sle = getLineElement(sc) + const ele = getLineElement(ec) + if (!sle || !ele) return null + if (!container.contains(sle as Node) || !container.contains(ele as Node)) return null + const cc = container.querySelector("code") as HTMLElement | null + if (!cc) return null + const lines = Array.from(cc.querySelectorAll(".line")) + const sli = lines.indexOf(sle as Element) + const eli = lines.indexOf(ele as Element) + if (sli === -1 || eli === -1) return null + const sl = sli + 1 + const el = eli + 1 + const sch = getCharacterOffsetInLine(sle as Element, sc, r.startOffset) + const ech = getCharacterOffsetInLine(ele as Element, ec, r.endOffset) + return { sl, sch, el, ech } +} diff --git a/Agent-backend/packages/app/src/utils/id.ts b/Agent-backend/packages/app/src/utils/id.ts new file mode 100644 index 000000000..fa27cf4c5 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/id.ts @@ -0,0 +1,99 @@ +import z from "zod" + +const prefixes = { + session: "ses", + message: "msg", + permission: "per", + user: "usr", + part: "prt", + pty: "pty", +} as const + +const LENGTH = 26 +let lastTimestamp = 0 +let counter = 0 + +type Prefix = keyof typeof prefixes +export namespace Identifier { + export function schema(prefix: Prefix) { + return z.string().startsWith(prefixes[prefix]) + } + + export function ascending(prefix: Prefix, given?: string) { + return generateID(prefix, false, given) + } + + export function descending(prefix: Prefix, given?: string) { + return generateID(prefix, true, given) + } +} + +function generateID(prefix: Prefix, descending: boolean, given?: string): string { + if (!given) { + return create(prefix, descending) + } + + if (!given.startsWith(prefixes[prefix])) { + throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) + } + + return given +} + +function create(prefix: Prefix, descending: boolean, timestamp?: number): string { + const currentTimestamp = timestamp ?? Date.now() + + if (currentTimestamp !== lastTimestamp) { + lastTimestamp = currentTimestamp + counter = 0 + } + + counter += 1 + + let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + + if (descending) { + now = ~now + } + + const timeBytes = new Uint8Array(6) + for (let i = 0; i < 6; i += 1) { + timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + } + + return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12) +} + +function bytesToHex(bytes: Uint8Array): string { + let hex = "" + for (let i = 0; i < bytes.length; i += 1) { + hex += bytes[i].toString(16).padStart(2, "0") + } + return hex +} + +function randomBase62(length: number): string { + const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + const bytes = getRandomBytes(length) + let result = "" + for (let i = 0; i < length; i += 1) { + result += chars[bytes[i] % 62] + } + return result +} + +function getRandomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length) + const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined + + if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { + cryptoObj.getRandomValues(bytes) + return bytes + } + + for (let i = 0; i < length; i += 1) { + bytes[i] = Math.floor(Math.random() * 256) + } + + return bytes +} diff --git a/Agent-backend/packages/app/src/utils/index.ts b/Agent-backend/packages/app/src/utils/index.ts new file mode 100644 index 000000000..d87053269 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./dom" diff --git a/Agent-backend/packages/app/src/utils/notification-click.test.ts b/Agent-backend/packages/app/src/utils/notification-click.test.ts new file mode 100644 index 000000000..fa81b0e02 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/notification-click.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { handleNotificationClick, setNavigate } from "./notification-click" + +describe("notification click", () => { + afterEach(() => { + setNavigate(undefined as any) + }) + + test("navigates via registered navigate function", () => { + const calls: string[] = [] + setNavigate((href) => calls.push(href)) + handleNotificationClick("/abc/session/123") + expect(calls).toEqual(["/abc/session/123"]) + }) + + test("does not navigate when href is missing", () => { + const calls: string[] = [] + setNavigate((href) => calls.push(href)) + handleNotificationClick(undefined) + expect(calls).toEqual([]) + }) + + test("falls back to location.assign without registered navigate", () => { + handleNotificationClick("/abc/session/123") + // falls back to window.location.assign — no error thrown + }) +}) diff --git a/Agent-backend/packages/app/src/utils/notification-click.ts b/Agent-backend/packages/app/src/utils/notification-click.ts new file mode 100644 index 000000000..316b27820 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/notification-click.ts @@ -0,0 +1,13 @@ +let nav: ((href: string) => void) | undefined + +export const setNavigate = (fn: (href: string) => void) => { + nav = fn +} + +export const handleNotificationClick = (href?: string) => { + window.focus() + if (!href) return + if (nav) return nav(href) + console.warn("notification-click: navigate function not set, falling back to window.location.assign") + window.location.assign(href) +} diff --git a/Agent-backend/packages/app/src/utils/persist.test.ts b/Agent-backend/packages/app/src/utils/persist.test.ts new file mode 100644 index 000000000..673acd224 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/persist.test.ts @@ -0,0 +1,115 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" + +type PersistTestingType = typeof import("./persist").PersistTesting + +class MemoryStorage implements Storage { + private values = new Map() + readonly events: string[] = [] + readonly calls = { get: 0, set: 0, remove: 0 } + + clear() { + this.values.clear() + } + + get length() { + return this.values.size + } + + key(index: number) { + return Array.from(this.values.keys())[index] ?? null + } + + getItem(key: string) { + this.calls.get += 1 + this.events.push(`get:${key}`) + if (key.startsWith("opencode.throw")) throw new Error("storage get failed") + return this.values.get(key) ?? null + } + + setItem(key: string, value: string) { + this.calls.set += 1 + this.events.push(`set:${key}`) + if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError") + if (key.startsWith("opencode.throw")) throw new Error("storage set failed") + this.values.set(key, value) + } + + removeItem(key: string) { + this.calls.remove += 1 + this.events.push(`remove:${key}`) + if (key.startsWith("opencode.throw")) throw new Error("storage remove failed") + this.values.delete(key) + } +} + +const storage = new MemoryStorage() + +let persistTesting: PersistTestingType + +beforeAll(async () => { + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ platform: "web" }), + })) + + const mod = await import("./persist") + persistTesting = mod.PersistTesting +}) + +beforeEach(() => { + storage.clear() + storage.events.length = 0 + storage.calls.get = 0 + storage.calls.set = 0 + storage.calls.remove = 0 + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + }) +}) + +describe("persist localStorage resilience", () => { + test("does not cache values as persisted when quota write and eviction fail", () => { + const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope") + storageApi.setItem("value", '{"value":1}') + + expect(storage.getItem("opencode.quota.scope:value")).toBeNull() + expect(storageApi.getItem("value")).toBeNull() + }) + + test("disables only the failing scope when storage throws", () => { + const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope") + bad.setItem("value", '{"value":1}') + + const before = storage.calls.set + bad.setItem("value", '{"value":2}') + expect(storage.calls.set).toBe(before) + expect(bad.getItem("value")).toBeNull() + + const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope") + healthy.setItem("value", '{"value":3}') + expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}') + }) + + test("failing fallback scope does not poison direct storage scope", () => { + const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2") + broken.setItem("value", '{"value":1}') + + const direct = persistTesting.localStorageDirect() + direct.setItem("direct-value", '{"value":5}') + + expect(storage.getItem("direct-value")).toBe('{"value":5}') + }) + + test("normalizer rejects malformed JSON payloads", () => { + const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}') + expect(result).toBeUndefined() + }) + + test("workspace storage sanitizes Windows filename characters", () => { + const result = persistTesting.workspaceStorage("C:\\Users\\foo") + + expect(result).toStartWith("opencode.workspace.") + expect(result.endsWith(".dat")).toBeTrue() + expect(/[:\\/]/.test(result)).toBeFalse() + }) +}) diff --git a/Agent-backend/packages/app/src/utils/persist.ts b/Agent-backend/packages/app/src/utils/persist.ts new file mode 100644 index 000000000..bee2f3e7d --- /dev/null +++ b/Agent-backend/packages/app/src/utils/persist.ts @@ -0,0 +1,464 @@ +import { Platform, usePlatform } from "@/context/platform" +import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" +import { checksum } from "@opencode-ai/util/encode" +import { createResource, type Accessor } from "solid-js" +import type { SetStoreFunction, Store } from "solid-js/store" + +type InitType = Promise | string | null +type PersistedWithReady = [Store, SetStoreFunction, InitType, Accessor] + +type PersistTarget = { + storage?: string + key: string + legacy?: string[] + migrate?: (value: unknown) => unknown +} + +const LEGACY_STORAGE = "default.dat" +const GLOBAL_STORAGE = "opencode.global.dat" +const LOCAL_PREFIX = "opencode." +const fallback = new Map() + +const CACHE_MAX_ENTRIES = 500 +const CACHE_MAX_BYTES = 8 * 1024 * 1024 + +type CacheEntry = { value: string; bytes: number } +const cache = new Map() +const cacheTotal = { bytes: 0 } + +function cacheDelete(key: string) { + const entry = cache.get(key) + if (!entry) return + cacheTotal.bytes -= entry.bytes + cache.delete(key) +} + +function cachePrune() { + for (;;) { + if (cache.size <= CACHE_MAX_ENTRIES && cacheTotal.bytes <= CACHE_MAX_BYTES) return + const oldest = cache.keys().next().value as string | undefined + if (!oldest) return + cacheDelete(oldest) + } +} + +function cacheSet(key: string, value: string) { + const bytes = value.length * 2 + if (bytes > CACHE_MAX_BYTES) { + cacheDelete(key) + return + } + + const entry = cache.get(key) + if (entry) cacheTotal.bytes -= entry.bytes + cache.delete(key) + cache.set(key, { value, bytes }) + cacheTotal.bytes += bytes + cachePrune() +} + +function cacheGet(key: string) { + const entry = cache.get(key) + if (!entry) return + cache.delete(key) + cache.set(key, entry) + return entry.value +} + +function fallbackDisabled(scope: string) { + return fallback.get(scope) === true +} + +function fallbackSet(scope: string) { + fallback.set(scope, true) +} + +function quota(error: unknown) { + if (error instanceof DOMException) { + if (error.name === "QuotaExceededError") return true + if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true + if (error.name === "QUOTA_EXCEEDED_ERR") return true + if (error.code === 22 || error.code === 1014) return true + return false + } + + if (!error || typeof error !== "object") return false + const name = (error as { name?: string }).name + if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true + if (name && /quota/i.test(name)) return true + + const code = (error as { code?: number }).code + if (code === 22 || code === 1014) return true + + const message = (error as { message?: string }).message + if (typeof message !== "string") return false + if (/quota/i.test(message)) return true + return false +} + +type Evict = { key: string; size: number } + +function evict(storage: Storage, keep: string, value: string) { + const total = storage.length + const indexes = Array.from({ length: total }, (_, index) => index) + const items: Evict[] = [] + + for (const index of indexes) { + const name = storage.key(index) + if (!name) continue + if (!name.startsWith(LOCAL_PREFIX)) continue + if (name === keep) continue + const stored = storage.getItem(name) + items.push({ key: name, size: stored?.length ?? 0 }) + } + + items.sort((a, b) => b.size - a.size) + + for (const item of items) { + storage.removeItem(item.key) + cacheDelete(item.key) + + try { + storage.setItem(keep, value) + cacheSet(keep, value) + return true + } catch (error) { + if (!quota(error)) throw error + } + } + + return false +} + +function write(storage: Storage, key: string, value: string) { + try { + storage.setItem(key, value) + cacheSet(key, value) + return true + } catch (error) { + if (!quota(error)) throw error + } + + try { + storage.removeItem(key) + cacheDelete(key) + storage.setItem(key, value) + cacheSet(key, value) + return true + } catch (error) { + if (!quota(error)) throw error + } + + const ok = evict(storage, key, value) + return ok +} + +function snapshot(value: unknown) { + return JSON.parse(JSON.stringify(value)) as unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function merge(defaults: unknown, value: unknown): unknown { + if (value === undefined) return defaults + if (value === null) return value + + if (Array.isArray(defaults)) { + if (Array.isArray(value)) return value + return defaults + } + + if (isRecord(defaults)) { + if (!isRecord(value)) return defaults + + const result: Record = { ...defaults } + for (const key of Object.keys(value)) { + if (key in defaults) { + result[key] = merge((defaults as Record)[key], (value as Record)[key]) + } else { + result[key] = (value as Record)[key] + } + } + return result + } + + return value +} + +function parse(value: string) { + try { + return JSON.parse(value) as unknown + } catch { + return undefined + } +} + +function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) { + const parsed = parse(raw) + if (parsed === undefined) return + const migrated = migrate ? migrate(parsed) : parsed + const merged = merge(defaults, migrated) + return JSON.stringify(merged) +} + +function workspaceStorage(dir: string) { + const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") + const sum = checksum(dir) ?? "0" + return `opencode.workspace.${head}.${sum}.dat` +} + +function localStorageWithPrefix(prefix: string): SyncStorage { + const base = `${prefix}:` + const scope = `prefix:${prefix}` + const item = (key: string) => base + key + return { + getItem: (key) => { + const name = item(key) + const cached = cacheGet(name) + if (fallbackDisabled(scope)) return cached ?? null + + const stored = (() => { + try { + return localStorage.getItem(name) + } catch { + fallbackSet(scope) + return null + } + })() + if (stored === null) return cached ?? null + cacheSet(name, stored) + return stored + }, + setItem: (key, value) => { + const name = item(key) + if (fallbackDisabled(scope)) return + try { + if (write(localStorage, name, value)) return + } catch { + fallbackSet(scope) + return + } + fallbackSet(scope) + }, + removeItem: (key) => { + const name = item(key) + cacheDelete(name) + if (fallbackDisabled(scope)) return + try { + localStorage.removeItem(name) + } catch { + fallbackSet(scope) + } + }, + } +} + +function localStorageDirect(): SyncStorage { + const scope = "direct" + return { + getItem: (key) => { + const cached = cacheGet(key) + if (fallbackDisabled(scope)) return cached ?? null + + const stored = (() => { + try { + return localStorage.getItem(key) + } catch { + fallbackSet(scope) + return null + } + })() + if (stored === null) return cached ?? null + cacheSet(key, stored) + return stored + }, + setItem: (key, value) => { + if (fallbackDisabled(scope)) return + try { + if (write(localStorage, key, value)) return + } catch { + fallbackSet(scope) + return + } + fallbackSet(scope) + }, + removeItem: (key) => { + cacheDelete(key) + if (fallbackDisabled(scope)) return + try { + localStorage.removeItem(key) + } catch { + fallbackSet(scope) + } + }, + } +} + +export const PersistTesting = { + localStorageDirect, + localStorageWithPrefix, + normalize, + workspaceStorage, +} + +export const Persist = { + global(key: string, legacy?: string[]): PersistTarget { + return { storage: GLOBAL_STORAGE, key, legacy } + }, + workspace(dir: string, key: string, legacy?: string[]): PersistTarget { + return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy } + }, + session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget { + return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy } + }, + scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget { + if (session) return Persist.session(dir, session, key, legacy) + return Persist.workspace(dir, key, legacy) + }, +} + +export function removePersisted(target: { storage?: string; key: string }, platform?: Platform) { + const isDesktop = platform?.platform === "desktop" && !!platform.storage + + if (isDesktop) { + return platform.storage?.(target.storage)?.removeItem(target.key) + } + + if (!target.storage) { + localStorageDirect().removeItem(target.key) + return + } + + localStorageWithPrefix(target.storage).removeItem(target.key) +} + +export function persisted( + target: string | PersistTarget, + store: [Store, SetStoreFunction], +): PersistedWithReady { + const platform = usePlatform() + const config: PersistTarget = typeof target === "string" ? { key: target } : target + + const defaults = snapshot(store[0]) + const legacy = config.legacy ?? [] + + const isDesktop = platform.platform === "desktop" && !!platform.storage + + const currentStorage = (() => { + if (isDesktop) return platform.storage?.(config.storage) + if (!config.storage) return localStorageDirect() + return localStorageWithPrefix(config.storage) + })() + + const legacyStorage = (() => { + if (!isDesktop) return localStorageDirect() + if (!config.storage) return platform.storage?.() + return platform.storage?.(LEGACY_STORAGE) + })() + + const storage = (() => { + if (!isDesktop) { + const current = currentStorage as SyncStorage + const legacyStore = legacyStorage as SyncStorage + + const api: SyncStorage = { + getItem: (key) => { + const raw = current.getItem(key) + if (raw !== null) { + const next = normalize(defaults, raw, config.migrate) + if (next === undefined) { + current.removeItem(key) + return null + } + if (raw !== next) current.setItem(key, next) + return next + } + + for (const legacyKey of legacy) { + const legacyRaw = legacyStore.getItem(legacyKey) + if (legacyRaw === null) continue + + const next = normalize(defaults, legacyRaw, config.migrate) + if (next === undefined) { + legacyStore.removeItem(legacyKey) + continue + } + current.setItem(key, next) + legacyStore.removeItem(legacyKey) + return next + } + + return null + }, + setItem: (key, value) => { + current.setItem(key, value) + }, + removeItem: (key) => { + current.removeItem(key) + }, + } + + return api + } + + const current = currentStorage as AsyncStorage + const legacyStore = legacyStorage as AsyncStorage | undefined + + const api: AsyncStorage = { + getItem: async (key) => { + const raw = await current.getItem(key) + if (raw !== null) { + const next = normalize(defaults, raw, config.migrate) + if (next === undefined) { + await current.removeItem(key).catch(() => undefined) + return null + } + if (raw !== next) await current.setItem(key, next) + return next + } + + if (!legacyStore) return null + + for (const legacyKey of legacy) { + const legacyRaw = await legacyStore.getItem(legacyKey) + if (legacyRaw === null) continue + + const next = normalize(defaults, legacyRaw, config.migrate) + if (next === undefined) { + await legacyStore.removeItem(legacyKey).catch(() => undefined) + continue + } + await current.setItem(key, next) + await legacyStore.removeItem(legacyKey) + return next + } + + return null + }, + setItem: async (key, value) => { + await current.setItem(key, value) + }, + removeItem: async (key) => { + await current.removeItem(key) + }, + } + + return api + })() + + const [state, setState, init] = makePersisted(store, { name: config.key, storage }) + + const isAsync = init instanceof Promise + const [ready] = createResource( + () => init, + async (initValue) => { + if (initValue instanceof Promise) await initValue + return true + }, + { initialValue: !isAsync }, + ) + + return [state, setState, init, () => ready() === true] +} diff --git a/Agent-backend/packages/app/src/utils/prompt.ts b/Agent-backend/packages/app/src/utils/prompt.ts new file mode 100644 index 000000000..35aec0071 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/prompt.ts @@ -0,0 +1,203 @@ +import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2" +import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" + +type Inline = + | { + type: "file" + start: number + end: number + value: string + path: string + selection?: { + startLine: number + endLine: number + startChar: number + endChar: number + } + } + | { + type: "agent" + start: number + end: number + value: string + name: string + } + +function selectionFromFileUrl(url: string): Extract["selection"] { + const queryIndex = url.indexOf("?") + if (queryIndex === -1) return undefined + const params = new URLSearchParams(url.slice(queryIndex + 1)) + const startLine = Number(params.get("start")) + const endLine = Number(params.get("end")) + if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined + return { + startLine, + endLine, + startChar: 0, + endChar: 0, + } +} + +function textPartValue(parts: Part[]) { + const candidates = parts + .filter((part): part is TextPart => part.type === "text") + .filter((part) => !part.synthetic && !part.ignored) + return candidates.reduce((best: TextPart | undefined, part) => { + if (!best) return part + if (part.text.length > best.text.length) return part + return best + }, undefined) +} + +/** + * Extract prompt content from message parts for restoring into the prompt input. + * This is used by undo to restore the original user prompt. + */ +export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt { + const textPart = textPartValue(parts) + const text = textPart?.text ?? "" + const directory = opts?.directory + const attachmentName = opts?.attachmentName ?? "attachment" + + const toRelative = (path: string) => { + if (!directory) return path + + const prefix = directory.endsWith("/") ? directory : directory + "/" + if (path.startsWith(prefix)) return path.slice(prefix.length) + + if (path.startsWith(directory)) { + const next = path.slice(directory.length) + if (next.startsWith("/")) return next.slice(1) + return next + } + + return path + } + + const inline: Inline[] = [] + const images: ImageAttachmentPart[] = [] + + for (const part of parts) { + if (part.type === "file") { + const filePart = part as FilePart + const sourceText = filePart.source?.text + if (sourceText) { + const value = sourceText.value + const start = sourceText.start + const end = sourceText.end + let path = value + if (value.startsWith("@")) path = value.slice(1) + if (!value.startsWith("@") && filePart.source && "path" in filePart.source) { + path = filePart.source.path + } + inline.push({ + type: "file", + start, + end, + value, + path: toRelative(path), + selection: selectionFromFileUrl(filePart.url), + }) + continue + } + + if (filePart.url.startsWith("data:")) { + images.push({ + type: "image", + id: filePart.id, + filename: filePart.filename ?? attachmentName, + mime: filePart.mime, + dataUrl: filePart.url, + }) + } + } + + if (part.type === "agent") { + const agentPart = part as MessageAgentPart + const source = agentPart.source + if (!source) continue + inline.push({ + type: "agent", + start: source.start, + end: source.end, + value: source.value, + name: agentPart.name, + }) + } + } + + inline.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start + return a.end - b.end + }) + + const result: Prompt = [] + let position = 0 + let cursor = 0 + + const pushText = (content: string) => { + if (!content) return + result.push({ + type: "text", + content, + start: position, + end: position + content.length, + }) + position += content.length + } + + const pushFile = (item: Extract) => { + const content = item.value + const attachment: FileAttachmentPart = { + type: "file", + path: item.path, + content, + start: position, + end: position + content.length, + selection: item.selection, + } + result.push(attachment) + position += content.length + } + + const pushAgent = (item: Extract) => { + const content = item.value + const mention: AgentPart = { + type: "agent", + name: item.name, + content, + start: position, + end: position + content.length, + } + result.push(mention) + position += content.length + } + + for (const item of inline) { + if (item.start < 0 || item.end < item.start) continue + + const expected = item.value + if (!expected) continue + + const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected + const start = mismatch ? text.indexOf(expected, cursor) : item.start + if (start === -1) continue + const end = mismatch ? start + expected.length : item.end + + pushText(text.slice(cursor, start)) + + if (item.type === "file") pushFile(item) + if (item.type === "agent") pushAgent(item) + + cursor = end + } + + pushText(text.slice(cursor)) + + if (result.length === 0) { + result.push({ type: "text", content: "", start: 0, end: 0 }) + } + + if (images.length === 0) return result + return [...result, ...images] +} diff --git a/Agent-backend/packages/app/src/utils/runtime-adapters.test.ts b/Agent-backend/packages/app/src/utils/runtime-adapters.test.ts new file mode 100644 index 000000000..9f408b8eb --- /dev/null +++ b/Agent-backend/packages/app/src/utils/runtime-adapters.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { + disposeIfDisposable, + getHoveredLinkText, + getSpeechRecognitionCtor, + hasSetOption, + isDisposable, + setOptionIfSupported, +} from "./runtime-adapters" + +describe("runtime adapters", () => { + test("detects and disposes disposable values", () => { + let count = 0 + const value = { + dispose: () => { + count += 1 + }, + } + expect(isDisposable(value)).toBe(true) + disposeIfDisposable(value) + expect(count).toBe(1) + }) + + test("ignores non-disposable values", () => { + expect(isDisposable({ dispose: "nope" })).toBe(false) + expect(() => disposeIfDisposable({ dispose: "nope" })).not.toThrow() + }) + + test("sets options only when setter exists", () => { + const calls: Array<[string, unknown]> = [] + const value = { + setOption: (key: string, next: unknown) => { + calls.push([key, next]) + }, + } + expect(hasSetOption(value)).toBe(true) + setOptionIfSupported(value, "fontFamily", "Berkeley Mono") + expect(calls).toEqual([["fontFamily", "Berkeley Mono"]]) + expect(() => setOptionIfSupported({}, "fontFamily", "Berkeley Mono")).not.toThrow() + }) + + test("reads hovered link text safely", () => { + expect(getHoveredLinkText({ currentHoveredLink: { text: "https://example.com" } })).toBe("https://example.com") + expect(getHoveredLinkText({ currentHoveredLink: { text: 1 } })).toBeUndefined() + expect(getHoveredLinkText(null)).toBeUndefined() + }) + + test("resolves speech recognition constructor with webkit precedence", () => { + class SpeechCtor {} + class WebkitCtor {} + const ctor = getSpeechRecognitionCtor({ + SpeechRecognition: SpeechCtor, + webkitSpeechRecognition: WebkitCtor, + }) + expect(ctor).toBe(WebkitCtor) + }) + + test("returns undefined when no valid speech constructor exists", () => { + expect(getSpeechRecognitionCtor({ SpeechRecognition: "nope" })).toBeUndefined() + expect(getSpeechRecognitionCtor(undefined)).toBeUndefined() + }) +}) diff --git a/Agent-backend/packages/app/src/utils/runtime-adapters.ts b/Agent-backend/packages/app/src/utils/runtime-adapters.ts new file mode 100644 index 000000000..4c74da5dc --- /dev/null +++ b/Agent-backend/packages/app/src/utils/runtime-adapters.ts @@ -0,0 +1,39 @@ +type RecordValue = Record + +const isRecord = (value: unknown): value is RecordValue => { + return typeof value === "object" && value !== null +} + +export const isDisposable = (value: unknown): value is { dispose: () => void } => { + return isRecord(value) && typeof value.dispose === "function" +} + +export const disposeIfDisposable = (value: unknown) => { + if (!isDisposable(value)) return + value.dispose() +} + +export const hasSetOption = (value: unknown): value is { setOption: (key: string, next: unknown) => void } => { + return isRecord(value) && typeof value.setOption === "function" +} + +export const setOptionIfSupported = (value: unknown, key: string, next: unknown) => { + if (!hasSetOption(value)) return + value.setOption(key, next) +} + +export const getHoveredLinkText = (value: unknown) => { + if (!isRecord(value)) return + const link = value.currentHoveredLink + if (!isRecord(link)) return + if (typeof link.text !== "string") return + return link.text +} + +export const getSpeechRecognitionCtor = (value: unknown): (new () => T) | undefined => { + if (!isRecord(value)) return + const ctor = + typeof value.webkitSpeechRecognition === "function" ? value.webkitSpeechRecognition : value.SpeechRecognition + if (typeof ctor !== "function") return + return ctor as new () => T +} diff --git a/Agent-backend/packages/app/src/utils/same.ts b/Agent-backend/packages/app/src/utils/same.ts new file mode 100644 index 000000000..c956f9299 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/same.ts @@ -0,0 +1,6 @@ +export function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} diff --git a/Agent-backend/packages/app/src/utils/scoped-cache.test.ts b/Agent-backend/packages/app/src/utils/scoped-cache.test.ts new file mode 100644 index 000000000..0c6189daf --- /dev/null +++ b/Agent-backend/packages/app/src/utils/scoped-cache.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { createScopedCache } from "./scoped-cache" + +describe("createScopedCache", () => { + test("evicts least-recently-used entry when max is reached", () => { + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key }), { + maxEntries: 2, + dispose: (value) => disposed.push(value.key), + }) + + const a = cache.get("a") + const b = cache.get("b") + expect(a.key).toBe("a") + expect(b.key).toBe("b") + + cache.get("a") + const c = cache.get("c") + + expect(c.key).toBe("c") + expect(cache.peek("a")?.key).toBe("a") + expect(cache.peek("b")).toBeUndefined() + expect(cache.peek("c")?.key).toBe("c") + expect(disposed).toEqual(["b"]) + }) + + test("disposes entries on delete and clear", () => { + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key }), { + dispose: (value) => disposed.push(value.key), + }) + + cache.get("a") + cache.get("b") + + const removed = cache.delete("a") + expect(removed?.key).toBe("a") + expect(cache.peek("a")).toBeUndefined() + + cache.clear() + expect(cache.peek("b")).toBeUndefined() + expect(disposed).toEqual(["a", "b"]) + }) + + test("expires stale entries with ttl and recreates on get", () => { + let clock = 0 + let count = 0 + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key, count: ++count }), { + ttlMs: 10, + now: () => clock, + dispose: (value) => disposed.push(`${value.key}:${value.count}`), + }) + + const first = cache.get("a") + expect(first.count).toBe(1) + + clock = 9 + expect(cache.peek("a")?.count).toBe(1) + + clock = 11 + expect(cache.peek("a")).toBeUndefined() + expect(disposed).toEqual(["a:1"]) + + const second = cache.get("a") + expect(second.count).toBe(2) + expect(disposed).toEqual(["a:1"]) + }) +}) diff --git a/Agent-backend/packages/app/src/utils/scoped-cache.ts b/Agent-backend/packages/app/src/utils/scoped-cache.ts new file mode 100644 index 000000000..224c363c1 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/scoped-cache.ts @@ -0,0 +1,104 @@ +type ScopedCacheOptions = { + maxEntries?: number + ttlMs?: number + dispose?: (value: T, key: string) => void + now?: () => number +} + +type Entry = { + value: T + touchedAt: number +} + +export function createScopedCache(createValue: (key: string) => T, options: ScopedCacheOptions = {}) { + const store = new Map>() + const now = options.now ?? Date.now + + const dispose = (key: string, entry: Entry) => { + options.dispose?.(entry.value, key) + } + + const expired = (entry: Entry) => { + if (options.ttlMs === undefined) return false + return now() - entry.touchedAt >= options.ttlMs + } + + const sweep = () => { + if (options.ttlMs === undefined) return + for (const [key, entry] of store) { + if (!expired(entry)) continue + store.delete(key) + dispose(key, entry) + } + } + + const touch = (key: string, entry: Entry) => { + entry.touchedAt = now() + store.delete(key) + store.set(key, entry) + } + + const prune = () => { + if (options.maxEntries === undefined) return + while (store.size > options.maxEntries) { + const key = store.keys().next().value + if (!key) return + const entry = store.get(key) + store.delete(key) + if (!entry) continue + dispose(key, entry) + } + } + + const remove = (key: string) => { + const entry = store.get(key) + if (!entry) return + store.delete(key) + dispose(key, entry) + return entry.value + } + + const peek = (key: string) => { + sweep() + const entry = store.get(key) + if (!entry) return + if (!expired(entry)) return entry.value + store.delete(key) + dispose(key, entry) + } + + const get = (key: string) => { + sweep() + const entry = store.get(key) + if (entry && !expired(entry)) { + touch(key, entry) + return entry.value + } + if (entry) { + store.delete(key) + dispose(key, entry) + } + + const created = { + value: createValue(key), + touchedAt: now(), + } + store.set(key, created) + prune() + return created.value + } + + const clear = () => { + for (const [key, entry] of store) { + dispose(key, entry) + } + store.clear() + } + + return { + get, + peek, + delete: remove, + clear, + } +} diff --git a/Agent-backend/packages/app/src/utils/server-errors.test.ts b/Agent-backend/packages/app/src/utils/server-errors.test.ts new file mode 100644 index 000000000..1f53bb8cf --- /dev/null +++ b/Agent-backend/packages/app/src/utils/server-errors.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test" +import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors" +import { formatServerError, parseReadableConfigInvalidError } from "./server-errors" + +function fill(text: string, vars?: Record) { + if (!vars) return text + return text.replace(/{{\s*(\w+)\s*}}/g, (_, key: string) => { + const value = vars[key] + if (value === undefined) return "" + return String(value) + }) +} + +function useLanguageMock() { + const dict: Record = { + "error.chain.unknown": "Erro desconhecido", + "error.chain.configInvalid": "Arquivo de config em {{path}} invalido", + "error.chain.configInvalidWithMessage": "Arquivo de config em {{path}} invalido: {{message}}", + "error.chain.modelNotFound": "Modelo nao encontrado: {{provider}}/{{model}}", + "error.chain.didYouMean": "Voce quis dizer: {{suggestions}}", + "error.chain.checkConfig": "Revise provider/model no config", + } + return { + t(key: string, vars?: Record) { + const text = dict[key] + if (!text) return key + return fill(text, vars) + }, + } +} + +const language = useLanguageMock() + +describe("parseReadableConfigInvalidError", () => { + test("formats issues with file path", () => { + const error = { + name: "ConfigInvalidError", + data: { + path: "opencode.config.ts", + issues: [ + { path: ["settings", "host"], message: "Required" }, + { path: ["mode"], message: "Invalid" }, + ], + }, + } satisfies ConfigInvalidError + + const result = parseReadableConfigInvalidError(error, language.t) + + expect(result).toBe( + ["Arquivo de config em opencode.config.ts invalido: settings.host: Required", "mode: Invalid"].join("\n"), + ) + }) + + test("uses trimmed message when issues are missing", () => { + const error = { + name: "ConfigInvalidError", + data: { + path: "config", + message: " Bad value ", + }, + } satisfies ConfigInvalidError + + const result = parseReadableConfigInvalidError(error, language.t) + + expect(result).toBe("Arquivo de config em config invalido: Bad value") + }) +}) + +describe("formatServerError", () => { + test("formats config invalid errors", () => { + const error = { + name: "ConfigInvalidError", + data: { + message: "Missing host", + }, + } satisfies ConfigInvalidError + + const result = formatServerError(error, language.t) + + expect(result).toBe("Arquivo de config em config invalido: Missing host") + }) + + test("returns error messages", () => { + expect(formatServerError(new Error("Request failed with status 503"), language.t)).toBe( + "Request failed with status 503", + ) + }) + + test("returns provided string errors", () => { + expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server") + }) + + test("uses translated unknown fallback", () => { + expect(formatServerError(0, language.t)).toBe("Erro desconhecido") + }) + + test("falls back for unknown error objects and names", () => { + expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } }, language.t)).toBe( + "Erro desconhecido", + ) + }) + + test("formats provider model errors using provider/model", () => { + const error = { + name: "ProviderModelNotFoundError", + data: { + providerID: "openai", + modelID: "gpt-4.1", + }, + } satisfies ProviderModelNotFoundError + + expect(formatServerError(error, language.t)).toBe( + ["Modelo nao encontrado: openai/gpt-4.1", "Revise provider/model no config"].join("\n"), + ) + }) + + test("formats provider model suggestions", () => { + const error = { + name: "ProviderModelNotFoundError", + data: { + providerID: "x", + modelID: "y", + suggestions: ["x/y2", "x/y3"], + }, + } satisfies ProviderModelNotFoundError + + expect(formatServerError(error, language.t)).toBe( + ["Modelo nao encontrado: x/y", "Voce quis dizer: x/y2, x/y3", "Revise provider/model no config"].join("\n"), + ) + }) +}) diff --git a/Agent-backend/packages/app/src/utils/server-errors.ts b/Agent-backend/packages/app/src/utils/server-errors.ts new file mode 100644 index 000000000..f0e1f72c5 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/server-errors.ts @@ -0,0 +1,80 @@ +export type ConfigInvalidError = { + name: "ConfigInvalidError" + data: { + path?: string + message?: string + issues?: Array<{ message: string; path: string[] }> + } +} + +export type ProviderModelNotFoundError = { + name: "ProviderModelNotFoundError" + data: { + providerID: string + modelID: string + suggestions?: string[] + } +} + +type Translator = (key: string, vars?: Record) => string + +function tr(translator: Translator | undefined, key: string, text: string, vars?: Record) { + if (!translator) return text + const out = translator(key, vars) + if (!out || out === key) return text + return out +} + +export function formatServerError(error: unknown, translate?: Translator, fallback?: string) { + if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate) + if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate) + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error) return error + if (fallback) return fallback + return tr(translate, "error.chain.unknown", "Unknown error") +} + +function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError { + if (typeof error !== "object" || error === null) return false + const o = error as Record + return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null +} + +function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError { + if (typeof error !== "object" || error === null) return false + const o = error as Record + return o.name === "ProviderModelNotFoundError" && typeof o.data === "object" && o.data !== null +} + +export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, translator?: Translator) { + const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config" + const detail = errorInput.data.message?.trim() ?? "" + const issues = (errorInput.data.issues ?? []) + .map((issue) => { + const msg = issue.message.trim() + if (!issue.path.length) return msg + return `${issue.path.join(".")}: ${msg}` + }) + .filter(Boolean) + const msg = issues.length ? issues.join("\n") : detail + if (!msg) return tr(translator, "error.chain.configInvalid", `Config file at ${file} is invalid`, { path: file }) + return tr(translator, "error.chain.configInvalidWithMessage", `Config file at ${file} is invalid: ${msg}`, { + path: file, + message: msg, + }) +} + +function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator) { + const p = errorInput.data.providerID.trim() + const m = errorInput.data.modelID.trim() + const list = (errorInput.data.suggestions ?? []).map((v) => v.trim()).filter(Boolean) + const body = tr(translator, "error.chain.modelNotFound", `Model not found: ${p}/${m}`, { provider: p, model: m }) + const tail = tr(translator, "error.chain.checkConfig", "Check your config (orbit.json) provider/model names") + if (list.length) { + const suggestions = list.slice(0, 5).join(", ") + return [body, tr(translator, "error.chain.didYouMean", `Did you mean: ${suggestions}`, { suggestions }), tail].join( + "\n", + ) + } + return [body, tail].join("\n") +} diff --git a/Agent-backend/packages/app/src/utils/server-health.test.ts b/Agent-backend/packages/app/src/utils/server-health.test.ts new file mode 100644 index 000000000..50082dcf3 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/server-health.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import type { ServerConnection } from "@/context/server" +import { checkServerHealth } from "./server-health" + +const server: ServerConnection.HttpBase = { + url: "http://localhost:4096", +} + +function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { + if (init?.signal) return init.signal + if (input instanceof Request) return input.signal + return undefined +} + +describe("checkServerHealth", () => { + test("returns healthy response with version", async () => { + const fetch = (async () => + new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch) + + expect(result).toEqual({ healthy: true, version: "1.2.3" }) + }) + + test("returns unhealthy when request fails", async () => { + const fetch = (async () => { + throw new Error("network") + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch) + + expect(result).toEqual({ healthy: false }) + }) + + test("uses timeout fallback when AbortSignal.timeout is unavailable", async () => { + const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout") + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value: undefined, + }) + + let aborted = false + const fetch = ((input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = abortFromInput(input, init) + signal?.addEventListener( + "abort", + () => { + aborted = true + reject(new DOMException("Aborted", "AbortError")) + }, + { once: true }, + ) + })) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch, { + timeoutMs: 10, + }).finally(() => { + if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout) + if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout") + }) + + expect(aborted).toBe(true) + expect(result).toEqual({ healthy: false }) + }) + + test("uses provided abort signal", async () => { + let signal: AbortSignal | undefined + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + signal = abortFromInput(input, init) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof globalThis.fetch + + const abort = new AbortController() + await checkServerHealth(server, fetch, { + signal: abort.signal, + }) + + expect(signal).toBe(abort.signal) + }) + + test("retries transient failures and eventually succeeds", async () => { + let count = 0 + const fetch = (async () => { + count += 1 + if (count < 3) throw new TypeError("network") + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch, { + retryCount: 2, + retryDelayMs: 1, + }) + + expect(count).toBe(3) + expect(result).toEqual({ healthy: true, version: "1.2.3" }) + }) + + test("returns unhealthy when retries are exhausted", async () => { + let count = 0 + const fetch = (async () => { + count += 1 + throw new TypeError("network") + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch, { + retryCount: 2, + retryDelayMs: 1, + }) + + expect(count).toBe(3) + expect(result).toEqual({ healthy: false }) + }) +}) diff --git a/Agent-backend/packages/app/src/utils/server-health.ts b/Agent-backend/packages/app/src/utils/server-health.ts new file mode 100644 index 000000000..db4aa89bd --- /dev/null +++ b/Agent-backend/packages/app/src/utils/server-health.ts @@ -0,0 +1,83 @@ +import type { ServerConnection } from "@/context/server" +import { createSdkForServer } from "./server" + +export type ServerHealth = { healthy: boolean; version?: string } + +interface CheckServerHealthOptions { + timeoutMs?: number + signal?: AbortSignal + retryCount?: number + retryDelayMs?: number +} + +const defaultTimeoutMs = 3000 +const defaultRetryCount = 2 +const defaultRetryDelayMs = 100 + +function timeoutSignal(timeoutMs: number) { + const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout + if (timeout) { + try { + return { + signal: timeout.call(AbortSignal, timeoutMs), + clear: undefined as (() => void) | undefined, + } + } catch {} + } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + return { signal: controller.signal, clear: () => clearTimeout(timer) } +} + +function wait(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")) + return + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(timer) + reject(new DOMException("Aborted", "AbortError")) + } + signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +function retryable(error: unknown, signal?: AbortSignal) { + if (signal?.aborted) return false + if (!(error instanceof Error)) return false + if (error.name === "AbortError" || error.name === "TimeoutError") return false + if (error instanceof TypeError) return true + return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message) +} + +export async function checkServerHealth( + server: ServerConnection.HttpBase, + fetch: typeof globalThis.fetch, + opts?: CheckServerHealthOptions, +): Promise { + const timeout = opts?.signal ? undefined : timeoutSignal(opts?.timeoutMs ?? defaultTimeoutMs) + const signal = opts?.signal ?? timeout?.signal + const retryCount = opts?.retryCount ?? defaultRetryCount + const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs + const next = (count: number, error: unknown) => { + if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const) + return wait(retryDelayMs * (count + 1), signal) + .then(() => attempt(count + 1)) + .catch(() => ({ healthy: false })) + } + const attempt = (count: number): Promise => + createSdkForServer({ + server, + fetch, + signal, + }) + .global.health() + .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) + .catch((error) => next(count, error)) + return attempt(0).finally(() => timeout?.clear?.()) +} diff --git a/Agent-backend/packages/app/src/utils/server.ts b/Agent-backend/packages/app/src/utils/server.ts new file mode 100644 index 000000000..5b44491c2 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/server.ts @@ -0,0 +1,22 @@ +import { createOrbitClient } from "@opencode-ai/sdk/v2/client" +import type { ServerConnection } from "@/context/server" + +export function createSdkForServer({ + server, + ...config +}: Omit[0]>, "baseUrl"> & { + server: ServerConnection.HttpBase +}) { + const auth = (() => { + if (!server.password) return + return { + Authorization: `Basic ${btoa(`${server.username ?? "orbit"}:${server.password}`)}`, + } + })() + + return createOrbitClient({ + ...config, + headers: { ...config.headers, ...auth }, + baseUrl: server.url, + }) +} diff --git a/Agent-backend/packages/app/src/utils/solid-dnd.tsx b/Agent-backend/packages/app/src/utils/solid-dnd.tsx new file mode 100644 index 000000000..8e30a033a --- /dev/null +++ b/Agent-backend/packages/app/src/utils/solid-dnd.tsx @@ -0,0 +1,49 @@ +import { useDragDropContext } from "@thisbeyond/solid-dnd" +import type { Transformer } from "@thisbeyond/solid-dnd" +import { createRoot, onCleanup, type JSXElement } from "solid-js" + +type DragEvent = { draggable?: { id?: unknown } } + +const isDragEvent = (event: unknown): event is DragEvent => { + if (typeof event !== "object" || event === null) return false + return "draggable" in event +} + +export const getDraggableId = (event: unknown): string | undefined => { + if (!isDragEvent(event)) return undefined + const draggable = event.draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined +} + +const createTransformer = (id: string, axis: "x" | "y"): Transformer => ({ + id, + order: 100, + callback: (transform) => (axis === "x" ? { ...transform, x: 0 } : { ...transform, y: 0 }), +}) + +const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSXElement => { + const context = useDragDropContext() + if (!context) return null + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context + const transformer = createTransformer(transformerId, axis) + const dispose = createRoot((dispose) => { + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) + }) + return dispose + }) + onCleanup(dispose) + return null +} + +export const ConstrainDragXAxis = createAxisConstraint("x", "constrain-x-axis") + +export const ConstrainDragYAxis = createAxisConstraint("y", "constrain-y-axis") diff --git a/Agent-backend/packages/app/src/utils/sound.ts b/Agent-backend/packages/app/src/utils/sound.ts new file mode 100644 index 000000000..6dea812ec --- /dev/null +++ b/Agent-backend/packages/app/src/utils/sound.ts @@ -0,0 +1,117 @@ +import alert01 from "@opencode-ai/ui/audio/alert-01.aac" +import alert02 from "@opencode-ai/ui/audio/alert-02.aac" +import alert03 from "@opencode-ai/ui/audio/alert-03.aac" +import alert04 from "@opencode-ai/ui/audio/alert-04.aac" +import alert05 from "@opencode-ai/ui/audio/alert-05.aac" +import alert06 from "@opencode-ai/ui/audio/alert-06.aac" +import alert07 from "@opencode-ai/ui/audio/alert-07.aac" +import alert08 from "@opencode-ai/ui/audio/alert-08.aac" +import alert09 from "@opencode-ai/ui/audio/alert-09.aac" +import alert10 from "@opencode-ai/ui/audio/alert-10.aac" +import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac" +import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac" +import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac" +import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac" +import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac" +import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac" +import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac" +import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac" +import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac" +import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac" +import nope01 from "@opencode-ai/ui/audio/nope-01.aac" +import nope02 from "@opencode-ai/ui/audio/nope-02.aac" +import nope03 from "@opencode-ai/ui/audio/nope-03.aac" +import nope04 from "@opencode-ai/ui/audio/nope-04.aac" +import nope05 from "@opencode-ai/ui/audio/nope-05.aac" +import nope06 from "@opencode-ai/ui/audio/nope-06.aac" +import nope07 from "@opencode-ai/ui/audio/nope-07.aac" +import nope08 from "@opencode-ai/ui/audio/nope-08.aac" +import nope09 from "@opencode-ai/ui/audio/nope-09.aac" +import nope10 from "@opencode-ai/ui/audio/nope-10.aac" +import nope11 from "@opencode-ai/ui/audio/nope-11.aac" +import nope12 from "@opencode-ai/ui/audio/nope-12.aac" +import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac" +import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac" +import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac" +import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac" +import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac" +import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac" +import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac" +import yup01 from "@opencode-ai/ui/audio/yup-01.aac" +import yup02 from "@opencode-ai/ui/audio/yup-02.aac" +import yup03 from "@opencode-ai/ui/audio/yup-03.aac" +import yup04 from "@opencode-ai/ui/audio/yup-04.aac" +import yup05 from "@opencode-ai/ui/audio/yup-05.aac" +import yup06 from "@opencode-ai/ui/audio/yup-06.aac" + +export const SOUND_OPTIONS = [ + { id: "alert-01", label: "sound.option.alert01", src: alert01 }, + { id: "alert-02", label: "sound.option.alert02", src: alert02 }, + { id: "alert-03", label: "sound.option.alert03", src: alert03 }, + { id: "alert-04", label: "sound.option.alert04", src: alert04 }, + { id: "alert-05", label: "sound.option.alert05", src: alert05 }, + { id: "alert-06", label: "sound.option.alert06", src: alert06 }, + { id: "alert-07", label: "sound.option.alert07", src: alert07 }, + { id: "alert-08", label: "sound.option.alert08", src: alert08 }, + { id: "alert-09", label: "sound.option.alert09", src: alert09 }, + { id: "alert-10", label: "sound.option.alert10", src: alert10 }, + { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 }, + { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 }, + { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 }, + { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 }, + { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 }, + { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 }, + { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 }, + { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 }, + { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 }, + { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 }, + { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 }, + { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 }, + { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 }, + { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 }, + { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 }, + { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 }, + { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 }, + { id: "nope-01", label: "sound.option.nope01", src: nope01 }, + { id: "nope-02", label: "sound.option.nope02", src: nope02 }, + { id: "nope-03", label: "sound.option.nope03", src: nope03 }, + { id: "nope-04", label: "sound.option.nope04", src: nope04 }, + { id: "nope-05", label: "sound.option.nope05", src: nope05 }, + { id: "nope-06", label: "sound.option.nope06", src: nope06 }, + { id: "nope-07", label: "sound.option.nope07", src: nope07 }, + { id: "nope-08", label: "sound.option.nope08", src: nope08 }, + { id: "nope-09", label: "sound.option.nope09", src: nope09 }, + { id: "nope-10", label: "sound.option.nope10", src: nope10 }, + { id: "nope-11", label: "sound.option.nope11", src: nope11 }, + { id: "nope-12", label: "sound.option.nope12", src: nope12 }, + { id: "yup-01", label: "sound.option.yup01", src: yup01 }, + { id: "yup-02", label: "sound.option.yup02", src: yup02 }, + { id: "yup-03", label: "sound.option.yup03", src: yup03 }, + { id: "yup-04", label: "sound.option.yup04", src: yup04 }, + { id: "yup-05", label: "sound.option.yup05", src: yup05 }, + { id: "yup-06", label: "sound.option.yup06", src: yup06 }, +] as const + +export type SoundOption = (typeof SOUND_OPTIONS)[number] +export type SoundID = SoundOption["id"] + +const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record + +export function soundSrc(id: string | undefined) { + if (!id) return + if (!(id in soundById)) return + return soundById[id as SoundID] +} + +export function playSound(src: string | undefined) { + if (typeof Audio === "undefined") return + if (!src) return + const audio = new Audio(src) + audio.play().catch(() => undefined) + + // Return a cleanup function to pause the sound. + return () => { + audio.pause() + audio.currentTime = 0 + } +} diff --git a/Agent-backend/packages/app/src/utils/speech.ts b/Agent-backend/packages/app/src/utils/speech.ts new file mode 100644 index 000000000..52fc46b69 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/speech.ts @@ -0,0 +1,326 @@ +import { onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { getSpeechRecognitionCtor } from "@/utils/runtime-adapters" + +// Minimal types to avoid relying on non-standard DOM typings +type RecognitionResult = { + 0: { transcript: string } + isFinal: boolean +} + +type RecognitionEvent = { + results: RecognitionResult[] + resultIndex: number +} + +interface Recognition { + continuous: boolean + interimResults: boolean + lang: string + start: () => void + stop: () => void + onresult: ((e: RecognitionEvent) => void) | null + onerror: ((e: { error: string }) => void) | null + onend: (() => void) | null + onstart: (() => void) | null +} + +const COMMIT_DELAY = 250 + +const appendSegment = (base: string, addition: string) => { + const trimmed = addition.trim() + if (!trimmed) return base + if (!base) return trimmed + const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed) + return `${base}${needsSpace ? " " : ""}${trimmed}` +} + +const extractSuffix = (committed: string, hypothesis: string) => { + const cleanHypothesis = hypothesis.trim() + if (!cleanHypothesis) return "" + const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : [] + const hypothesisTokens = cleanHypothesis.split(/\s+/) + let index = 0 + while ( + index < baseTokens.length && + index < hypothesisTokens.length && + baseTokens[index] === hypothesisTokens[index] + ) { + index += 1 + } + if (index < baseTokens.length) return "" + return hypothesisTokens.slice(index).join(" ") +} + +export function createSpeechRecognition(opts?: { + lang?: string + onFinal?: (text: string) => void + onInterim?: (text: string) => void +}) { + const ctor = getSpeechRecognitionCtor(typeof window === "undefined" ? undefined : window) + const hasSupport = Boolean(ctor) + + const [store, setStore] = createStore({ + isRecording: false, + committed: "", + interim: "", + }) + + const isRecording = () => store.isRecording + const committed = () => store.committed + const interim = () => store.interim + + let recognition: Recognition | undefined + let shouldContinue = false + let committedText = "" + let sessionCommitted = "" + let pendingHypothesis = "" + let lastInterimSuffix = "" + let shrinkCandidate: string | undefined + let commitTimer: number | undefined + let restartTimer: number | undefined + + const cancelPendingCommit = () => { + if (commitTimer === undefined) return + clearTimeout(commitTimer) + commitTimer = undefined + } + + const clearRestart = () => { + if (restartTimer === undefined) return + window.clearTimeout(restartTimer) + restartTimer = undefined + } + + const scheduleRestart = () => { + clearRestart() + if (!shouldContinue) return + if (!recognition) return + restartTimer = window.setTimeout(() => { + restartTimer = undefined + if (!shouldContinue) return + if (!recognition) return + try { + recognition.start() + } catch {} + }, 150) + } + + const commitSegment = (segment: string) => { + const nextCommitted = appendSegment(committedText, segment) + if (nextCommitted === committedText) return + committedText = nextCommitted + setStore("committed", committedText) + if (opts?.onFinal) opts.onFinal(segment.trim()) + } + + const promotePending = () => { + if (!pendingHypothesis) return + const suffix = extractSuffix(sessionCommitted, pendingHypothesis) + if (!suffix) { + pendingHypothesis = "" + return + } + sessionCommitted = appendSegment(sessionCommitted, suffix) + commitSegment(suffix) + pendingHypothesis = "" + lastInterimSuffix = "" + shrinkCandidate = undefined + setStore("interim", "") + if (opts?.onInterim) opts.onInterim("") + } + + const applyInterim = (suffix: string, hypothesis: string) => { + cancelPendingCommit() + pendingHypothesis = hypothesis + lastInterimSuffix = suffix + shrinkCandidate = undefined + setStore("interim", suffix) + if (opts?.onInterim) { + opts.onInterim(suffix ? appendSegment(committedText, suffix) : "") + } + if (!suffix) return + const snapshot = hypothesis + commitTimer = window.setTimeout(() => { + if (pendingHypothesis !== snapshot) return + const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis) + if (!currentSuffix) return + sessionCommitted = appendSegment(sessionCommitted, currentSuffix) + commitSegment(currentSuffix) + pendingHypothesis = "" + lastInterimSuffix = "" + shrinkCandidate = undefined + setStore("interim", "") + if (opts?.onInterim) opts.onInterim("") + }, COMMIT_DELAY) + } + + if (ctor) { + recognition = new ctor() + recognition.continuous = false + recognition.interimResults = true + recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US") + + recognition.onresult = (event: RecognitionEvent) => { + if (!event.results.length) return + + let aggregatedFinal = "" + let latestHypothesis = "" + + for (let i = 0; i < event.results.length; i += 1) { + const result = event.results[i] + const transcript = (result[0]?.transcript || "").trim() + if (!transcript) continue + if (result.isFinal) { + aggregatedFinal = appendSegment(aggregatedFinal, transcript) + } else { + latestHypothesis = transcript + } + } + + if (aggregatedFinal) { + cancelPendingCommit() + const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal) + if (finalSuffix) { + sessionCommitted = appendSegment(sessionCommitted, finalSuffix) + commitSegment(finalSuffix) + } + pendingHypothesis = "" + lastInterimSuffix = "" + shrinkCandidate = undefined + setStore("interim", "") + if (opts?.onInterim) opts.onInterim("") + return + } + + cancelPendingCommit() + + if (!latestHypothesis) { + shrinkCandidate = undefined + applyInterim("", "") + return + } + + const suffix = extractSuffix(sessionCommitted, latestHypothesis) + + if (!suffix) { + if (!lastInterimSuffix) { + shrinkCandidate = undefined + applyInterim("", latestHypothesis) + return + } + if (shrinkCandidate === "") { + applyInterim("", latestHypothesis) + return + } + shrinkCandidate = "" + pendingHypothesis = latestHypothesis + return + } + + if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) { + if (shrinkCandidate === suffix) { + applyInterim(suffix, latestHypothesis) + return + } + shrinkCandidate = suffix + pendingHypothesis = latestHypothesis + return + } + + shrinkCandidate = undefined + applyInterim(suffix, latestHypothesis) + } + + recognition.onerror = (e: { error: string }) => { + clearRestart() + cancelPendingCommit() + lastInterimSuffix = "" + shrinkCandidate = undefined + if (e.error === "no-speech" && shouldContinue) { + setStore("interim", "") + if (opts?.onInterim) opts.onInterim("") + scheduleRestart() + return + } + shouldContinue = false + setStore("isRecording", false) + } + + recognition.onstart = () => { + clearRestart() + sessionCommitted = "" + pendingHypothesis = "" + cancelPendingCommit() + lastInterimSuffix = "" + shrinkCandidate = undefined + setStore("interim", "") + if (opts?.onInterim) opts.onInterim("") + setStore("isRecording", true) + } + + recognition.onend = () => { + clearRestart() + cancelPendingCommit() + lastInterimSuffix = "" + shrinkCandidate = undefined + setStore("isRecording", false) + if (shouldContinue) { + scheduleRestart() + } + } + } + + const start = () => { + if (!recognition) return + clearRestart() + shouldContinue = true + sessionCommitted = "" + pendingHypothesis = "" + cancelPendingCommit() + lastInterimSuffix = "" + shrinkCandidate = undefined + setStore("interim", "") + try { + recognition.start() + } catch {} + } + + const stop = () => { + if (!recognition) return + shouldContinue = false + clearRestart() + promotePending() + cancelPendingCommit() + lastInterimSuffix = "" + shrinkCandidate = undefined + setStore("interim", "") + if (opts?.onInterim) opts.onInterim("") + try { + recognition.stop() + } catch {} + } + + onCleanup(() => { + shouldContinue = false + clearRestart() + promotePending() + cancelPendingCommit() + lastInterimSuffix = "" + shrinkCandidate = undefined + setStore("interim", "") + if (opts?.onInterim) opts.onInterim("") + try { + recognition?.stop() + } catch {} + }) + + return { + isSupported: () => hasSupport, + isRecording, + committed, + interim, + start, + stop, + } +} diff --git a/Agent-backend/packages/app/src/utils/terminal-writer.test.ts b/Agent-backend/packages/app/src/utils/terminal-writer.test.ts new file mode 100644 index 000000000..c49702e39 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/terminal-writer.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { terminalWriter } from "./terminal-writer" + +describe("terminalWriter", () => { + test("buffers and flushes once per schedule", () => { + const calls: string[] = [] + const scheduled: VoidFunction[] = [] + const writer = terminalWriter( + (data, done) => { + calls.push(data) + done?.() + }, + (flush) => scheduled.push(flush), + ) + + writer.push("a") + writer.push("b") + writer.push("c") + + expect(calls).toEqual([]) + expect(scheduled).toHaveLength(1) + + scheduled[0]?.() + expect(calls).toEqual(["abc"]) + }) + + test("flush is a no-op when empty", () => { + const calls: string[] = [] + const writer = terminalWriter( + (data, done) => { + calls.push(data) + done?.() + }, + (flush) => flush(), + ) + writer.flush() + expect(calls).toEqual([]) + }) + + test("flush waits for pending write completion", () => { + const calls: string[] = [] + let done: VoidFunction | undefined + const writer = terminalWriter( + (data, finish) => { + calls.push(data) + done = finish + }, + (flush) => flush(), + ) + + writer.push("a") + + let settled = false + writer.flush(() => { + settled = true + }) + + expect(calls).toEqual(["a"]) + expect(settled).toBe(false) + + done?.() + expect(settled).toBe(true) + }) +}) diff --git a/Agent-backend/packages/app/src/utils/terminal-writer.ts b/Agent-backend/packages/app/src/utils/terminal-writer.ts new file mode 100644 index 000000000..083f51de4 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/terminal-writer.ts @@ -0,0 +1,65 @@ +export function terminalWriter( + write: (data: string, done?: VoidFunction) => void, + schedule: (flush: VoidFunction) => void = queueMicrotask, +) { + let chunks: string[] | undefined + let waits: VoidFunction[] | undefined + let scheduled = false + let writing = false + + const settle = () => { + if (scheduled || writing || chunks?.length) return + const list = waits + if (!list?.length) return + waits = undefined + for (const fn of list) { + fn() + } + } + + const run = () => { + if (writing) return + scheduled = false + const items = chunks + if (!items?.length) { + settle() + return + } + chunks = undefined + writing = true + write(items.join(""), () => { + writing = false + if (chunks?.length) { + if (scheduled) return + scheduled = true + schedule(run) + return + } + settle() + }) + } + + const push = (data: string) => { + if (!data) return + if (chunks) chunks.push(data) + else chunks = [data] + + if (scheduled || writing) return + scheduled = true + schedule(run) + } + + const flush = (done?: VoidFunction) => { + if (!scheduled && !writing && !chunks?.length) { + done?.() + return + } + if (done) { + if (waits) waits.push(done) + else waits = [done] + } + run() + } + + return { push, flush } +} diff --git a/Agent-backend/packages/app/src/utils/time.ts b/Agent-backend/packages/app/src/utils/time.ts new file mode 100644 index 000000000..d183e1080 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/time.ts @@ -0,0 +1,22 @@ +type TimeKey = + | "common.time.justNow" + | "common.time.minutesAgo.short" + | "common.time.hoursAgo.short" + | "common.time.daysAgo.short" + +type Translate = (key: TimeKey, params?: Record) => string + +export function getRelativeTime(dateString: string, t: Translate): string { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSeconds = Math.floor(diffMs / 1000) + const diffMinutes = Math.floor(diffSeconds / 60) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSeconds < 60) return t("common.time.justNow") + if (diffMinutes < 60) return t("common.time.minutesAgo.short", { count: diffMinutes }) + if (diffHours < 24) return t("common.time.hoursAgo.short", { count: diffHours }) + return t("common.time.daysAgo.short", { count: diffDays }) +} diff --git a/Agent-backend/packages/app/src/utils/uuid.test.ts b/Agent-backend/packages/app/src/utils/uuid.test.ts new file mode 100644 index 000000000..e6b4e2824 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/uuid.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { uuid } from "./uuid" + +const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto") +const secureDescriptor = Object.getOwnPropertyDescriptor(globalThis, "isSecureContext") +const randomDescriptor = Object.getOwnPropertyDescriptor(Math, "random") + +const setCrypto = (value: Partial) => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: value as Crypto, + }) +} + +const setSecure = (value: boolean) => { + Object.defineProperty(globalThis, "isSecureContext", { + configurable: true, + value, + }) +} + +const setRandom = (value: () => number) => { + Object.defineProperty(Math, "random", { + configurable: true, + value, + }) +} + +afterEach(() => { + if (cryptoDescriptor) { + Object.defineProperty(globalThis, "crypto", cryptoDescriptor) + } + + if (secureDescriptor) { + Object.defineProperty(globalThis, "isSecureContext", secureDescriptor) + } + + if (!secureDescriptor) { + delete (globalThis as { isSecureContext?: boolean }).isSecureContext + } + + if (randomDescriptor) { + Object.defineProperty(Math, "random", randomDescriptor) + } +}) + +describe("uuid", () => { + test("uses randomUUID in secure contexts", () => { + setCrypto({ randomUUID: () => "00000000-0000-0000-0000-000000000000" }) + setSecure(true) + expect(uuid()).toBe("00000000-0000-0000-0000-000000000000") + }) + + test("falls back in insecure contexts", () => { + setCrypto({ randomUUID: () => "00000000-0000-0000-0000-000000000000" }) + setSecure(false) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) + + test("falls back when randomUUID throws", () => { + setCrypto({ + randomUUID: () => { + throw new DOMException("Failed", "OperationError") + }, + }) + setSecure(true) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) + + test("falls back when randomUUID is unavailable", () => { + setCrypto({}) + setSecure(true) + setRandom(() => 0.5) + expect(uuid()).toBe("8") + }) +}) diff --git a/Agent-backend/packages/app/src/utils/uuid.ts b/Agent-backend/packages/app/src/utils/uuid.ts new file mode 100644 index 000000000..7b964068c --- /dev/null +++ b/Agent-backend/packages/app/src/utils/uuid.ts @@ -0,0 +1,12 @@ +const fallback = () => Math.random().toString(16).slice(2) + +export function uuid() { + const c = globalThis.crypto + if (!c || typeof c.randomUUID !== "function") return fallback() + if (typeof globalThis.isSecureContext === "boolean" && !globalThis.isSecureContext) return fallback() + try { + return c.randomUUID() + } catch { + return fallback() + } +} diff --git a/Agent-backend/packages/app/src/utils/worktree.test.ts b/Agent-backend/packages/app/src/utils/worktree.test.ts new file mode 100644 index 000000000..8161e7ad8 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/worktree.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { Worktree } from "./worktree" + +const dir = (name: string) => `/tmp/opencode-worktree-${name}-${crypto.randomUUID()}` + +describe("Worktree", () => { + test("normalizes trailing slashes", () => { + const key = dir("normalize") + Worktree.ready(`${key}/`) + + expect(Worktree.get(key)).toEqual({ status: "ready" }) + }) + + test("pending does not overwrite a terminal state", () => { + const key = dir("pending") + Worktree.failed(key, "boom") + Worktree.pending(key) + + expect(Worktree.get(key)).toEqual({ status: "failed", message: "boom" }) + }) + + test("wait resolves shared pending waiter when ready", async () => { + const key = dir("wait-ready") + Worktree.pending(key) + + const a = Worktree.wait(key) + const b = Worktree.wait(`${key}/`) + + expect(a).toBe(b) + + Worktree.ready(key) + + expect(await a).toEqual({ status: "ready" }) + expect(await b).toEqual({ status: "ready" }) + }) + + test("wait resolves with failure message", async () => { + const key = dir("wait-failed") + const waiting = Worktree.wait(key) + + Worktree.failed(key, "permission denied") + + expect(await waiting).toEqual({ status: "failed", message: "permission denied" }) + expect(await Worktree.wait(key)).toEqual({ status: "failed", message: "permission denied" }) + }) +}) diff --git a/Agent-backend/packages/app/src/utils/worktree.ts b/Agent-backend/packages/app/src/utils/worktree.ts new file mode 100644 index 000000000..581afd553 --- /dev/null +++ b/Agent-backend/packages/app/src/utils/worktree.ts @@ -0,0 +1,73 @@ +const normalize = (directory: string) => directory.replace(/[\\/]+$/, "") + +type State = + | { + status: "pending" + } + | { + status: "ready" + } + | { + status: "failed" + message: string + } + +const state = new Map() +const waiters = new Map< + string, + { + promise: Promise + resolve: (state: State) => void + } +>() + +function deferred() { + const box = { resolve: (_: State) => {} } + const promise = new Promise((resolve) => { + box.resolve = resolve + }) + return { promise, resolve: box.resolve } +} + +export const Worktree = { + get(directory: string) { + return state.get(normalize(directory)) + }, + pending(directory: string) { + const key = normalize(directory) + const current = state.get(key) + if (current && current.status !== "pending") return + state.set(key, { status: "pending" }) + }, + ready(directory: string) { + const key = normalize(directory) + const next = { status: "ready" } as const + state.set(key, next) + const waiter = waiters.get(key) + if (!waiter) return + waiters.delete(key) + waiter.resolve(next) + }, + failed(directory: string, message: string) { + const key = normalize(directory) + const next = { status: "failed", message } as const + state.set(key, next) + const waiter = waiters.get(key) + if (!waiter) return + waiters.delete(key) + waiter.resolve(next) + }, + wait(directory: string) { + const key = normalize(directory) + const current = state.get(key) + if (current && current.status !== "pending") return Promise.resolve(current) + + const existing = waiters.get(key) + if (existing) return existing.promise + + const waiter = deferred() + + waiters.set(key, waiter) + return waiter.promise + }, +} diff --git a/Agent-backend/packages/app/tsconfig.json b/Agent-backend/packages/app/tsconfig.json new file mode 100644 index 000000000..e2a27dd5d --- /dev/null +++ b/Agent-backend/packages/app/tsconfig.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "composite": true, + "target": "ESNext", + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "allowJs": true, + "resolveJsonModule": true, + "strict": true, + "noEmit": false, + "emitDeclarationOnly": true, + "outDir": "node_modules/.ts-dist", + "isolatedModules": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "package.json"], + "exclude": ["dist", "ts-dist"] +} diff --git a/Agent-backend/packages/app/vite.config.ts b/Agent-backend/packages/app/vite.config.ts new file mode 100644 index 000000000..6a29ae634 --- /dev/null +++ b/Agent-backend/packages/app/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite" +import desktopPlugin from "./vite" + +export default defineConfig({ + plugins: [desktopPlugin] as any, + server: { + host: "0.0.0.0", + allowedHosts: true, + port: 3000, + }, + build: { + target: "esnext", + // sourcemap: true, + }, +}) diff --git a/Agent-backend/packages/app/vite.js b/Agent-backend/packages/app/vite.js new file mode 100644 index 000000000..6b8fd6137 --- /dev/null +++ b/Agent-backend/packages/app/vite.js @@ -0,0 +1,26 @@ +import solidPlugin from "vite-plugin-solid" +import tailwindcss from "@tailwindcss/vite" +import { fileURLToPath } from "url" + +/** + * @type {import("vite").PluginOption} + */ +export default [ + { + name: "opencode-desktop:config", + config() { + return { + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + worker: { + format: "es", + }, + } + }, + }, + tailwindcss(), + solidPlugin(), +] diff --git a/Agent-backend/packages/desktop/.gitignore b/Agent-backend/packages/desktop/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/Agent-backend/packages/desktop/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/Agent-backend/packages/desktop/AGENTS.md b/Agent-backend/packages/desktop/AGENTS.md new file mode 100644 index 000000000..3839db1a9 --- /dev/null +++ b/Agent-backend/packages/desktop/AGENTS.md @@ -0,0 +1,4 @@ +# Desktop package notes + +- Never call `invoke` manually in this package. +- Use the generated bindings in `packages/desktop/src/bindings.ts` for core commands/events. diff --git a/Agent-backend/packages/desktop/CLAUDE.md b/Agent-backend/packages/desktop/CLAUDE.md new file mode 100644 index 000000000..b80025b30 --- /dev/null +++ b/Agent-backend/packages/desktop/CLAUDE.md @@ -0,0 +1,91 @@ +# packages/desktop + +> **Path:** `Agent-backend/packages/desktop/` + +## Purpose + +Tauri v2 desktop application wrapping the OpenCode web UI (`packages/app`). Bundles the `opencode` CLI as a sidecar, provides native OS integrations (clipboard, dialogs, notifications, deep links, auto-updates, window state persistence), and includes a Rust backend for server management, CLI proxying, and platform-specific window customization. + +## Usage Status + +| Product | Status | Notes | +| ------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Orbit Desktop (SDK) | `reference` | Orbit already has its own Tauri setup. This package is a reference for: sidecar bundling pattern, CLI-to-server bridge (`server.rs`), native menu integration, deep link handling, auto-updater, and platform-specific window customization. | +| Orbit CLI | `not used` | CLI is standalone | + +## Recommendation + +**KEEP AS REFERENCE** — Orbit already has its own Tauri app, but this shows how upstream solved the same problems (sidecar lifecycle, native platform abstraction, auto-updater, window customization, deep links). The Rust backend (`server.rs`, `window_customizer.rs`) is particularly relevant since Orbit's `src-tauri/` faces the same challenges. + +## When to Reference This + +| If you're building... | Read this file | What you'll learn | +| -------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **Sidecar process management** (spawn/restart/kill CLI binary) | `src/cli.ts` + `src-tauri/src/server.rs` | Sidecar lifecycle: spawn with args, health check polling, graceful shutdown, restart on crash | +| **Native Platform abstraction** (Tauri replacing web APIs) | `src/entry.tsx` | Which web APIs need Tauri overrides: clipboard, notifications, file dialogs, `openLink`, back/forward navigation, restart | +| **Auto-update system** | `src/updater.ts` | `@tauri-apps/plugin-updater` integration, update check flow, user notification | +| **Window chrome per-OS** (vibrancy, titlebar) | `src-tauri/src/window_customizer.rs` | macOS vibrancy, Windows mica, Linux GTK titlebar, transparent backgrounds | +| **Deep link protocol** (`opencode://`) | `src-tauri/tauri.conf.json` + deep-link plugin | Protocol registration, URL parsing, routing to correct session | +| **Native menu bar** | `src/menu.ts` | File/Edit/View/Window menus with Tauri menu API | +| **Loading screen** while sidecar starts | `src/loading.tsx` | Splash screen pattern before server is ready | +| **Tauri v2 capabilities/permissions** | `src-tauri/capabilities/` | How to define ACL permissions for Tauri plugins | +| **Multi-channel app icons** (dev/beta/prod) | `src-tauri/icons/` | Icon sets per release channel with platform variants (.icns, .ico, .png) | +| **Linux display server detection** | `src-tauri/src/linux_display.rs` | Wayland vs X11 detection and windowing differences | + +## Key Files + +### Frontend (`src/`) + +| File | Purpose | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `entry.tsx` | Tauri entry point — creates `Platform` with native APIs (Tauri clipboard, dialog, notifications, opener, shell, updater) | +| `index.tsx` | Root component — renders `AppInterface` with Tauri server connection | +| `cli.ts` | CLI sidecar management — spawn/manage the bundled opencode binary | +| `bindings.ts` | TypeScript bindings for Tauri Rust commands | +| `menu.ts` | Native menu bar integration | +| `updater.ts` | Auto-update system via `@tauri-apps/plugin-updater` | +| `loading.tsx` | Loading screen while sidecar starts | +| `webview-zoom.ts` | Zoom level management | +| `styles.css` | Desktop-specific styles | +| `i18n/` | Desktop-specific i18n overrides | + +### Rust Backend (`src-tauri/src/`) + +| File | Purpose | +| ----------------------------------------- | ---------------------------------------------------- | +| `lib.rs` + `main.rs` | Tauri app entry, plugin registration | +| `server.rs` | Manages the opencode server sidecar process | +| `cli.rs` | CLI command proxying from desktop to sidecar | +| `window_customizer.rs` | Platform-specific window chrome (titlebar, vibrancy) | +| `windows.rs` | Windows-specific adaptations | +| `linux_display.rs` + `linux_windowing.rs` | Linux display server detection and windowing | +| `logging.rs` | Structured logging for Rust side | +| `markdown.rs` | Markdown processing | +| `constants.rs` | App constants | +| `os/` | OS-specific utilities | + +### Config & Build + +| File | Purpose | +| --------------------------- | ------------------------------------------------------------------------- | +| `src-tauri/tauri.conf.json` | Tauri config — capabilities, permissions, sidecar definitions | +| `src-tauri/capabilities/` | Tauri v2 capability definitions | +| `src-tauri/icons/` | App icons per channel: `dev/`, `beta/`, `prod/` (macOS + Linux + Windows) | +| `scripts/predev.ts` | Pre-development setup script | +| `vite.config.ts` | Vite config for desktop frontend | + +## Tauri Plugins Used + +`clipboard-manager`, `deep-link`, `dialog`, `http`, `notification`, `opener`, `os`, `process`, `shell`, `store`, `updater`, `window-state` + +## Dependencies + +- **Internal:** `@opencode-ai/app` (web UI), `@opencode-ai/ui` (components) +- **Tauri:** `@tauri-apps/api` + 12 Tauri plugins +- **Frontend:** `solid-js`, `@solidjs/meta` + +## Notes + +- **Sidecar pattern** — the desktop app bundles the `opencode` CLI binary and spawns it as a subprocess. `server.rs` manages the sidecar lifecycle. This is the same pattern Orbit already uses (agent-bridge sidecar). +- **Three icon channels** — `dev/`, `beta/`, `prod/` in `src-tauri/icons/` with platform variants (macOS .icns, Windows .ico, Linux .png, Android mipmaps). Orbit has a similar multi-channel icon system. +- **`entry.tsx` creates a Tauri `Platform`** — overrides `notify`, `openLink`, `back`, `forward`, `restart` with native Tauri implementations (vs web's `window.open` / `history.back`). diff --git a/Agent-backend/packages/desktop/README.md b/Agent-backend/packages/desktop/README.md new file mode 100644 index 000000000..358b7d24d --- /dev/null +++ b/Agent-backend/packages/desktop/README.md @@ -0,0 +1,32 @@ +# OpenCode Desktop + +Native OpenCode desktop app, built with Tauri v2. + +## Prerequisites + +Building the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. + +## Development + +From the repo root: + +```bash +bun install +bun run --cwd packages/desktop tauri dev +``` + +## Build + +```bash +bun run --cwd packages/desktop tauri build +``` + +## Troubleshooting + +### Rust compiler not found + +If you see errors about Rust not being found, install it via [rustup](https://rustup.rs/): + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` diff --git a/Agent-backend/packages/desktop/index.html b/Agent-backend/packages/desktop/index.html new file mode 100644 index 000000000..ce2775a70 --- /dev/null +++ b/Agent-backend/packages/desktop/index.html @@ -0,0 +1,24 @@ + + + + + + OpenCode + + + + + + + + + + + + + +
+