diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e882696 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.DS_Store +*.log +.pi/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ede5018 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Adding Extensions + +1. Create `extensions//.ts` (or `index.ts` for multi-file extensions) +2. Add the entry point to `pi.extensions` in `package.json` +3. If the extension has npm dependencies, add a `package.json` in its directory and run `npm install` + +Skills go in `skills//SKILL.md`, themes in `themes/*.json`, prompts in `prompts/*.md`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b1c8263 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 tomsej + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c2c026 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +``` + ██▓███ ██▓ ▓█████ ▒██ ██▒▄▄▄█████▓ + ▓██░ ██▒▓██▒ ▓█ ▀ ▒▒ █ █ ▒░▓ ██▒ ▓▒ + ▓██░ ██▓▒▒██▒ ▄▄▄▄▄ ▒███ ░░ █ ░▒ ▓██░ ▒░ + ▒██▄█▓▒ ▒░██░ ░░░░░ ▒▓█ ▄ ░ █ █ ▒ ░ ▓██▓ ░ + ▒██▒ ░ ░░██░ ░▒████▒▒██▒ ▒██▒ ▒██▒ ░ + ▒▓▒░ ░ ░░▓░ ░░ ▒░ ░▒▒ ░ ░▓ ░ ▒ ░░ + ░▒ ░ ░▒░ ░ ░ ░░░ ░▒ ░ ░ + ░░ ░░ ░ ░ ░ ░ + ░ ░ ░ +``` + +A collection of extensions, skills, and themes for [Pi](https://github.com/badlogic/pi), the AI coding agent for the terminal. + +Extensions range from visual UI enhancements (custom footer, leader key palette, session switcher) to deep integrations (web search, code review, Ghostty terminal support). Everything is MIT licensed and designed to be installed together or individually. + +https://github.com/user-attachments/assets/PLACEHOLDER + +> *Leader key palette → model switcher → session picker → code review* + +## Install + +```bash +pi install github:tomsej/pi-ext +``` + +Or install individual extensions: + +```bash +pi install github:tomsej/pi-ext/extensions/leader-key +pi install github:tomsej/pi-ext/extensions/pi-web-access +``` + +Requires [Pi](https://github.com/badlogic/pi) v0.37.3+. + +## Extensions + +### [Leader Key](extensions/leader-key/) + +Press `Ctrl+Space` to open a floating command palette — like Vim's which-key or Emacs' leader key. Actions are organized into single-character groups (`s` for Session, `m` for Model, `f` for Favourites, `t` for Thinking level). Auto-discovers extension commands and merges them with built-in actions. + +Includes sub-modules: +- **Model Switcher** — searchable provider → model → thinking level picker +- **Favourite Models** — quick-switch to preset model+thinking combos via `favourite-models.json` +- **Thinking Picker** — adjust reasoning effort (off, minimal, low, medium, high, xhigh) + +### [Session Switcher](extensions/session-switch/) + +Split-panel session picker with Pi's native session selector on the left and a live conversation preview on the right. Search, rename, delete, and resume sessions without leaving the TUI. + +### [Model Switcher](extensions/model-switcher/) + +Standalone multi-step model selector with searchable lists: pick a provider, pick a model, pick a thinking level. Available as `/switch` command or `Ctrl+Shift+M`. Also used internally by the leader key extension. + +### [Custom Footer](extensions/custom-footer/) + +Replaces Pi's default footer with a compact powerline-style status bar: + +``` +~/project (main) │ ↑12k ↓8k $0.42 │ 42%/200k │ ⚡ claude-sonnet-4 • medium +``` + +Shows working directory, git branch, token usage, cost, context window utilization, and active model — all in a single line. + +### [Pi Web Access](extensions/pi-web-access/) + +Web search, content extraction, and video understanding. Zero config if you're signed into Google in Chrome, or bring your own Perplexity/Gemini API keys. Based on [pi-web-access](https://github.com/nicobailon/pi-web-access) by [Nico Bailon](https://github.com/nicobailon). + +**Tools:** `web_search`, `fetch_content`, `get_search_content` + +Capabilities: Perplexity & Gemini search, GitHub repo cloning, YouTube video understanding, local video analysis, PDF extraction, blocked-page fallbacks, interactive search curator UI. + +See the [full README](extensions/pi-web-access/README.md) for details. + +### [Code Review](extensions/review/) + +`/review` command with multiple modes: review a GitHub PR (checks it out locally), diff against a base branch, review uncommitted changes, review a specific commit, or provide custom review instructions. Supports project-specific `REVIEW_GUIDELINES.md`. Derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff) (Apache 2.0). + +### [Todos](extensions/todos/) + +File-based todo management stored in `.pi/todos/`. Each todo is a standalone markdown file with JSON front matter. Supports claiming (lock files), tagging, status tracking, and automatic GC of closed items. Comes with both a `/todos` TUI and LLM-facing tools (`todo` tool) for natural language task management. Derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff) (Apache 2.0). + +### [Context](extensions/context/) + +`/context` command showing what's loaded in the current session: extensions, skills, project context files (`AGENTS.md`/`CLAUDE.md`), context window usage, and session cost totals. Derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff) (Apache 2.0). + +### [Ghostty](extensions/ghostty/) + +[Ghostty](https://ghostty.org) terminal integration. Sets dynamic window titles with project, session, and model info. Animates a braille spinner and pulses Ghostty's native progress bar while the agent is working. Based on [pi-ghostty](https://github.com/HazAT/pi-ghostty) by [HazAT](https://github.com/HazAT). + +## Skills + +| Skill | Description | +|-------|-------------| +| [commit](skills/commit/) | Conventional Commits-style `git commit` — infers type, scope, and summary from the diff | +| [github](skills/github/) | Recipes for the `gh` CLI — PR checks, CI runs, issue queries, JSON output | +| [librarian](skills/librarian/) | Research open-source libraries with evidence-backed answers and GitHub permalinks | + +## Themes + +| Theme | Description | +|-------|-------------| +| [catppuccin-mocha](themes/catppuccin-mocha.json) | Dark theme based on [Catppuccin Mocha](https://github.com/catppuccin/catppuccin) | + +## Configuration + +Most extensions work out of the box. Notable config: + +- **Leader Key** — edit `extensions/leader-key/favourite-models.json` to set your favourite model presets +- **Pi Web Access** — optionally configure API keys in `~/.pi/web-search.json` (see [README](extensions/pi-web-access/README.md#configuration)) +- **Todos** — set `PI_TODO_PATH` env var to change the storage directory (defaults to `.pi/todos`) + +## License + +[MIT](LICENSE) © tomsej diff --git a/extensions/context/LICENSE b/extensions/context/LICENSE new file mode 100644 index 0000000..546ca4f --- /dev/null +++ b/extensions/context/LICENSE @@ -0,0 +1,19 @@ +Derived from mitsuhiko/agent-stuff (https://github.com/mitsuhiko/agent-stuff) + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +Copyright Armin Ronacher + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/extensions/context/context.ts b/extensions/context/context.ts new file mode 100644 index 0000000..469d8dc --- /dev/null +++ b/extensions/context/context.ts @@ -0,0 +1,578 @@ +/** + * /context + * + * Small TUI view showing what's loaded/available: + * - extensions (best-effort from registered extension slash commands) + * - skills + * - project context files (AGENTS.md / CLAUDE.md) + * - current context window usage + session totals (tokens/cost) + */ + +import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ToolResultEvent } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, Key, Text, matchesKey, type Component, type TUI } from "@mariozechner/pi-tui"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { existsSync } from "node:fs"; + +function formatUsd(cost: number): string { + if (!Number.isFinite(cost) || cost <= 0) return "$0.00"; + if (cost >= 1) return `$${cost.toFixed(2)}`; + if (cost >= 0.1) return `$${cost.toFixed(3)}`; + return `$${cost.toFixed(4)}`; +} + +function estimateTokens(text: string): number { + // Deliberately fuzzy (good enough for “how big-ish is this”). + return Math.max(0, Math.ceil(text.length / 4)); +} + +function normalizeReadPath(inputPath: string, cwd: string): string { + // Similar to pi's resolveToCwd/resolveReadPath, but simplified. + let p = inputPath; + if (p.startsWith("@")) p = p.slice(1); + if (p === "~") p = os.homedir(); + else if (p.startsWith("~/")) p = path.join(os.homedir(), p.slice(2)); + if (!path.isAbsolute(p)) p = path.resolve(cwd, p); + return path.resolve(p); +} + +function getAgentDir(): string { + // Mirrors pi's behavior reasonably well. + const envCandidates = ["PI_CODING_AGENT_DIR", "TAU_CODING_AGENT_DIR"]; + let envDir: string | undefined; + for (const k of envCandidates) { + if (process.env[k]) { + envDir = process.env[k]; + break; + } + } + if (!envDir) { + for (const [k, v] of Object.entries(process.env)) { + if (k.endsWith("_CODING_AGENT_DIR") && v) { + envDir = v; + break; + } + } + } + + if (envDir) { + if (envDir === "~") return os.homedir(); + if (envDir.startsWith("~/")) return path.join(os.homedir(), envDir.slice(2)); + return envDir; + } + return path.join(os.homedir(), ".pi", "agent"); +} + +async function readFileIfExists(filePath: string): Promise<{ path: string; content: string; bytes: number } | null> { + if (!existsSync(filePath)) return null; + try { + const buf = await fs.readFile(filePath); + return { path: filePath, content: buf.toString("utf8"), bytes: buf.byteLength }; + } catch { + return null; + } +} + +async function loadProjectContextFiles(cwd: string): Promise> { + const out: Array<{ path: string; tokens: number; bytes: number }> = []; + const seen = new Set(); + + const loadFromDir = async (dir: string) => { + for (const name of ["AGENTS.md", "CLAUDE.md"]) { + const p = path.join(dir, name); + const f = await readFileIfExists(p); + if (f && !seen.has(f.path)) { + seen.add(f.path); + out.push({ path: f.path, tokens: estimateTokens(f.content), bytes: f.bytes }); + // pi loads at most one of those per dir + return; + } + } + }; + + await loadFromDir(getAgentDir()); + + // Ancestors: root → cwd (same order as pi) + const stack: string[] = []; + let current = path.resolve(cwd); + while (true) { + stack.push(current); + const parent = path.resolve(current, ".."); + if (parent === current) break; + current = parent; + } + stack.reverse(); + for (const dir of stack) await loadFromDir(dir); + + return out; +} + +function normalizeSkillName(name: string): string { + return name.startsWith("skill:") ? name.slice("skill:".length) : name; +} + +type SkillIndexEntry = { + name: string; + skillFilePath: string; + skillDir: string; +}; + +function buildSkillIndex(pi: ExtensionAPI, cwd: string): SkillIndexEntry[] { + return pi + .getCommands() + .filter((c) => c.source === "skill") + .map((c) => { + const p = c.path ? normalizeReadPath(c.path, cwd) : ""; + return { + name: normalizeSkillName(c.name), + skillFilePath: p, + skillDir: p ? path.dirname(p) : "", + }; + }) + .filter((x) => x.name && x.skillDir); +} + +const SKILL_LOADED_ENTRY = "context:skill_loaded"; + +type SkillLoadedEntryData = { + name: string; + path: string; +}; + +function getLoadedSkillsFromSession(ctx: ExtensionContext): Set { + const out = new Set(); + for (const e of ctx.sessionManager.getEntries()) { + if ((e as any)?.type !== "custom") continue; + if ((e as any)?.customType !== SKILL_LOADED_ENTRY) continue; + const data = (e as any)?.data as SkillLoadedEntryData | undefined; + if (data?.name) out.add(data.name); + } + return out; +} + +function extractCostTotal(usage: any): number { + if (!usage) return 0; + const c = usage?.cost; + if (typeof c === "number") return Number.isFinite(c) ? c : 0; + if (typeof c === "string") { + const n = Number(c); + return Number.isFinite(n) ? n : 0; + } + const t = c?.total; + if (typeof t === "number") return Number.isFinite(t) ? t : 0; + if (typeof t === "string") { + const n = Number(t); + return Number.isFinite(n) ? n : 0; + } + return 0; +} + +function sumSessionUsage(ctx: ExtensionCommandContext): { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + totalCost: number; +} { + let input = 0; + let output = 0; + let cacheRead = 0; + let cacheWrite = 0; + let totalCost = 0; + + for (const entry of ctx.sessionManager.getEntries()) { + if ((entry as any)?.type !== "message") continue; + const msg = (entry as any)?.message; + if (!msg || msg.role !== "assistant") continue; + const usage = msg.usage; + if (!usage) continue; + input += Number(usage.inputTokens ?? 0) || 0; + output += Number(usage.outputTokens ?? 0) || 0; + cacheRead += Number(usage.cacheRead ?? 0) || 0; + cacheWrite += Number(usage.cacheWrite ?? 0) || 0; + totalCost += extractCostTotal(usage); + } + + return { + input, + output, + cacheRead, + cacheWrite, + totalTokens: input + output + cacheRead + cacheWrite, + totalCost, + }; +} + +function shortenPath(p: string, cwd: string): string { + const rp = path.resolve(p); + const rc = path.resolve(cwd); + if (rp === rc) return "."; + if (rp.startsWith(rc + path.sep)) return "./" + rp.slice(rc.length + 1); + return rp; +} + +function renderUsageBar( + theme: any, + parts: { system: number; tools: number; convo: number; remaining: number }, + total: number, + width: number, +): string { + const w = Math.max(10, width); + if (total <= 0) return ""; + + const toCols = (n: number) => Math.round((n / total) * w); + let sys = toCols(parts.system); + let tools = toCols(parts.tools); + let con = toCols(parts.convo); + let rem = w - sys - tools - con; + if (rem < 0) rem = 0; + // adjust rounding drift + while (sys + tools + con + rem < w) rem++; + while (sys + tools + con + rem > w && rem > 0) rem--; + + const block = "█"; + const sysStr = theme.fg("accent", block.repeat(sys)); + const toolsStr = theme.fg("warning", block.repeat(tools)); + const conStr = theme.fg("success", block.repeat(con)); + const remStr = theme.fg("dim", block.repeat(rem)); + return `${sysStr}${toolsStr}${conStr}${remStr}`; +} + +function joinComma(items: string[]): string { + return items.join(", "); +} + +function joinCommaStyled(items: string[], renderItem: (item: string) => string, sep: string): string { + return items.map(renderItem).join(sep); +} + +type ContextViewData = { + usage: + | { + // message-based context usage estimate from ctx.getContextUsage() + messageTokens: number; + contextWindow: number; + // effective usage incl. a rough tool-definition estimate + effectiveTokens: number; + percent: number; + remainingTokens: number; + systemPromptTokens: number; + agentTokens: number; + toolsTokens: number; + activeTools: number; + } + | null; + agentFiles: string[]; + extensions: string[]; + skills: string[]; + loadedSkills: string[]; + session: { totalTokens: number; totalCost: number }; +}; + +class ContextView implements Component { + private tui: TUI; + private theme: any; + private onDone: () => void; + private data: ContextViewData; + private container: Container; + private body: Text; + private cachedWidth?: number; + + constructor(tui: TUI, theme: any, data: ContextViewData, onDone: () => void) { + this.tui = tui; + this.theme = theme; + this.data = data; + this.onDone = onDone; + + this.container = new Container(); + this.container.addChild(new DynamicBorder((s) => theme.fg("accent", s))); + this.container.addChild( + new Text( + theme.fg("accent", theme.bold("Context")) + theme.fg("dim", " (Esc/q/Enter to close)"), + 1, + 0, + ), + ); + this.container.addChild(new Text("", 1, 0)); + + this.body = new Text("", 1, 0); + this.container.addChild(this.body); + + this.container.addChild(new Text("", 1, 0)); + this.container.addChild(new DynamicBorder((s) => theme.fg("accent", s))); + } + + private rebuild(width: number): void { + const muted = (s: string) => this.theme.fg("muted", s); + const dim = (s: string) => this.theme.fg("dim", s); + const text = (s: string) => this.theme.fg("text", s); + + const lines: string[] = []; + + // Window + bar + if (!this.data.usage) { + lines.push(muted("Window: ") + dim("(unknown)")); + } else { + const u = this.data.usage; + lines.push( + muted("Window: ") + + text(`~${u.effectiveTokens.toLocaleString()} / ${u.contextWindow.toLocaleString()}`) + + muted(` (${u.percent.toFixed(1)}% used, ~${u.remainingTokens.toLocaleString()} left)`), + ); + + // bar width tries to fit within the viewport + const barWidth = Math.max(10, Math.min(36, width - 10)); + + // Prorate system prompt into current message context estimate, then add tools estimate. + const sysInMessages = Math.min(u.systemPromptTokens, u.messageTokens); + const convoInMessages = Math.max(0, u.messageTokens - sysInMessages); + const bar = + renderUsageBar( + this.theme, + { + system: sysInMessages, + tools: u.toolsTokens, + convo: convoInMessages, + remaining: u.remainingTokens, + }, + u.contextWindow, + barWidth, + ) + + " " + + dim("sys") + + this.theme.fg("accent", "█") + + " " + + dim("tools") + + this.theme.fg("warning", "█") + + " " + + dim("convo") + + this.theme.fg("success", "█") + + " " + + dim("free") + + this.theme.fg("dim", "█"); + lines.push(bar); + } + + lines.push(""); + + // System prompt + tools totals (approx) + if (this.data.usage) { + const u = this.data.usage; + lines.push( + muted("System: ") + + text(`~${u.systemPromptTokens.toLocaleString()} tok`) + + muted(` (AGENTS ~${u.agentTokens.toLocaleString()})`), + ); + lines.push( + muted("Tools: ") + + text(`~${u.toolsTokens.toLocaleString()} tok`) + + muted(` (${u.activeTools} active)`), + ); + } + + lines.push(muted(`AGENTS (${this.data.agentFiles.length}): `) + text(this.data.agentFiles.length ? joinComma(this.data.agentFiles) : "(none)")); + lines.push(""); + lines.push(muted(`Extensions (${this.data.extensions.length}): `) + text(this.data.extensions.length ? joinComma(this.data.extensions) : "(none)")); + + const loaded = new Set(this.data.loadedSkills); + const skillsRendered = this.data.skills.length + ? joinCommaStyled( + this.data.skills, + (name) => (loaded.has(name) ? this.theme.fg("success", name) : this.theme.fg("muted", name)), + this.theme.fg("muted", ", "), + ) + : "(none)"; + lines.push(muted(`Skills (${this.data.skills.length}): `) + skillsRendered); + lines.push(""); + lines.push( + muted("Session: ") + + text(`${this.data.session.totalTokens.toLocaleString()} tokens`) + + muted(" · ") + + text(formatUsd(this.data.session.totalCost)), + ); + + this.body.setText(lines.join("\n")); + this.cachedWidth = width; + } + + handleInput(data: string): void { + if ( + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl("c")) || + data.toLowerCase() === "q" || + data === "\r" + ) { + this.onDone(); + return; + } + } + + invalidate(): void { + this.container.invalidate(); + this.cachedWidth = undefined; + } + + render(width: number): string[] { + if (this.cachedWidth !== width) this.rebuild(width); + return this.container.render(width); + } +} + +export default function contextExtension(pi: ExtensionAPI) { + // Track which skills were actually pulled in via read tool calls. + let lastSessionId: string | null = null; + let cachedLoadedSkills = new Set(); + let cachedSkillIndex: SkillIndexEntry[] = []; + + const ensureCaches = (ctx: ExtensionContext) => { + const sid = ctx.sessionManager.getSessionId(); + if (sid !== lastSessionId) { + lastSessionId = sid; + cachedLoadedSkills = getLoadedSkillsFromSession(ctx); + cachedSkillIndex = buildSkillIndex(pi, ctx.cwd); + } + if (cachedSkillIndex.length === 0) { + cachedSkillIndex = buildSkillIndex(pi, ctx.cwd); + } + }; + + const matchSkillForPath = (absPath: string): string | null => { + let best: SkillIndexEntry | null = null; + for (const s of cachedSkillIndex) { + if (!s.skillDir) continue; + if (absPath === s.skillFilePath || absPath.startsWith(s.skillDir + path.sep)) { + if (!best || s.skillDir.length > best.skillDir.length) best = s; + } + } + return best?.name ?? null; + }; + + pi.on("tool_result", (event: ToolResultEvent, ctx: ExtensionContext) => { + // Only count successful reads. + if ((event as any).toolName !== "read") return; + if ((event as any).isError) return; + + const input = (event as any).input as { path?: unknown } | undefined; + const p = typeof input?.path === "string" ? input.path : ""; + if (!p) return; + + ensureCaches(ctx); + const abs = normalizeReadPath(p, ctx.cwd); + const skillName = matchSkillForPath(abs); + if (!skillName) return; + + if (!cachedLoadedSkills.has(skillName)) { + cachedLoadedSkills.add(skillName); + pi.appendEntry(SKILL_LOADED_ENTRY, { name: skillName, path: abs }); + } + }); + + pi.registerCommand("context", { + description: "Show loaded context overview", + handler: async (_args, ctx: ExtensionCommandContext) => { + const commands = pi.getCommands(); + const extensionCmds = commands.filter((c) => c.source === "extension"); + const skillCmds = commands.filter((c) => c.source === "skill"); + + const extensionsByPath = new Map(); + for (const c of extensionCmds) { + const p = c.path ?? ""; + const arr = extensionsByPath.get(p) ?? []; + arr.push(c.name); + extensionsByPath.set(p, arr); + } + const extensionFiles = [...extensionsByPath.keys()] + .map((p) => (p === "" ? p : path.basename(p))) + .sort((a, b) => a.localeCompare(b)); + + const skills = skillCmds + .map((c) => normalizeSkillName(c.name)) + .sort((a, b) => a.localeCompare(b)); + + const agentFiles = await loadProjectContextFiles(ctx.cwd); + const agentFilePaths = agentFiles.map((f) => shortenPath(f.path, ctx.cwd)); + const agentTokens = agentFiles.reduce((a, f) => a + f.tokens, 0); + + const systemPrompt = ctx.getSystemPrompt(); + const systemPromptTokens = systemPrompt ? estimateTokens(systemPrompt) : 0; + + const usage = ctx.getContextUsage(); + const messageTokens = usage?.tokens ?? 0; + const ctxWindow = usage?.contextWindow ?? 0; + + // Tool definitions are not part of ctx.getContextUsage() (it estimates message tokens). + // We approximate their token impact from tool name + description, and apply a fudge + // factor to account for parameters/schema/formatting. + const TOOL_FUDGE = 1.5; + const activeToolNames = pi.getActiveTools(); + const toolInfoByName = new Map(pi.getAllTools().map((t) => [t.name, t] as const)); + let toolsTokens = 0; + for (const name of activeToolNames) { + const info = toolInfoByName.get(name); + const blob = `${name}\n${info?.description ?? ""}`; + toolsTokens += estimateTokens(blob); + } + toolsTokens = Math.round(toolsTokens * TOOL_FUDGE); + + const effectiveTokens = messageTokens + toolsTokens; + const percent = ctxWindow > 0 ? (effectiveTokens / ctxWindow) * 100 : 0; + const remainingTokens = ctxWindow > 0 ? Math.max(0, ctxWindow - effectiveTokens) : 0; + + const sessionUsage = sumSessionUsage(ctx); + + const makePlainText = () => { + const lines: string[] = []; + lines.push("Context"); + if (usage) { + lines.push( + `Window: ~${effectiveTokens.toLocaleString()} / ${ctxWindow.toLocaleString()} (${percent.toFixed(1)}% used, ~${remainingTokens.toLocaleString()} left)`, + ); + } else { + lines.push("Window: (unknown)"); + } + lines.push(`System: ~${systemPromptTokens.toLocaleString()} tok (AGENTS ~${agentTokens.toLocaleString()})`); + lines.push(`Tools: ~${toolsTokens.toLocaleString()} tok (${activeToolNames.length} active)`); + lines.push(`AGENTS: ${agentFilePaths.length ? joinComma(agentFilePaths) : "(none)"}`); + lines.push(`Extensions (${extensionFiles.length}): ${extensionFiles.length ? joinComma(extensionFiles) : "(none)"}`); + lines.push(`Skills (${skills.length}): ${skills.length ? joinComma(skills) : "(none)"}`); + lines.push(`Session: ${sessionUsage.totalTokens.toLocaleString()} tokens · ${formatUsd(sessionUsage.totalCost)}`); + return lines.join("\n"); + }; + + if (!ctx.hasUI) { + pi.sendMessage({ customType: "context", content: makePlainText(), display: true }, { triggerTurn: false }); + return; + } + + const loadedSkills = Array.from(getLoadedSkillsFromSession(ctx)).sort((a, b) => a.localeCompare(b)); + + const viewData: ContextViewData = { + usage: usage + ? { + messageTokens, + contextWindow: ctxWindow, + effectiveTokens, + percent, + remainingTokens, + systemPromptTokens, + agentTokens, + toolsTokens, + activeTools: activeToolNames.length, + } + : null, + agentFiles: agentFilePaths, + extensions: extensionFiles, + skills, + loadedSkills, + session: { totalTokens: sessionUsage.totalTokens, totalCost: sessionUsage.totalCost }, + }; + + await ctx.ui.custom((tui, theme, _kb, done) => { + return new ContextView(tui, theme, viewData, done); + }); + }, + }); +} diff --git a/extensions/custom-footer/custom-footer.ts b/extensions/custom-footer/custom-footer.ts new file mode 100644 index 0000000..7cc5e8e --- /dev/null +++ b/extensions/custom-footer/custom-footer.ts @@ -0,0 +1,139 @@ +/** + * Custom Footer Extension — Single-line compact powerline style + * + * ~/path (branch) │ ↑in ↓out $cost │ 42%/200k │ ⚡ model • thinking + * [gold] [dim] [dim] [color ctx] [accent] + */ + +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; + +function fmtTokens(n: number): string { + if (n < 1000) return n.toString(); + if (n < 10000) return `${(n / 1000).toFixed(1)}k`; + if (n < 1000000) return `${Math.round(n / 1000)}k`; + return `${(n / 1000000).toFixed(1)}M`; +} + +// Catppuccin Mocha Yellow — #f9e2af +const gold = (s: string) => `\x1b[38;2;249;226;175m${s}\x1b[0m`; + +export default function (pi: ExtensionAPI) { + pi.on("session_start", async (_event, ctx) => { + ctx.ui.setFooter((tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => tui.requestRender()); + + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + const sep = theme.fg("dim", " │ "); + const sepWidth = 3; // " │ " + + // ── Segment 1: path (branch) ─────────────────────────── + let pwd = process.cwd(); + const home = process.env.HOME || process.env.USERPROFILE; + if (home && pwd.startsWith(home)) pwd = `~${pwd.slice(home.length)}`; + const branch = footerData.getGitBranch(); + const pathRaw = pwd + (branch ? ` (${branch})` : ""); + + // ── Segment 2: token stats ────────────────────────────── + let totalIn = 0, + totalOut = 0, + totalCost = 0; + for (const e of ctx.sessionManager.getBranch()) { + if (e.type === "message" && e.message.role === "assistant") { + const m = e.message as AssistantMessage; + totalIn += m.usage.input; + totalOut += m.usage.output; + totalCost += m.usage.cost.total; + } + } + const statsParts: string[] = []; + if (totalIn || totalOut) { + statsParts.push(`↑${fmtTokens(totalIn)}`); + statsParts.push(`↓${fmtTokens(totalOut)}`); + } + if (totalCost) statsParts.push(`$${totalCost.toFixed(2)}`); + const statsRaw = statsParts.join(" "); + + // ── Segment 3: context % ──────────────────────────────── + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + const win = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0; + const ctxRaw = `${pct.toFixed(0)}%/${fmtTokens(win)}`; + + // ── Segment 4: (provider) model + thinking ────────────── + const provider = ctx.model?.provider || "unknown"; + const modelName = ctx.model?.id || "no-model"; + const thinking = pi.getThinkingLevel(); + const thinkSuffix = thinking !== "off" ? ` • ${thinking}` : ""; + const modelRaw = `⚡ ${modelName} (${provider})${thinkSuffix}`; + + // ── Assemble: figure out what fits ────────────────────── + // Always show: path | ctx | model. Stats only if room. + const fixedRight = ctxRaw + modelRaw; + const fixedRightWidth = visibleWidth(fixedRight); + const sepsNeeded = statsRaw ? 3 : 2; // number of separators + const sepsWidth = sepsNeeded * sepWidth; + + // Available width for path after reserving right segments + const rightBlockWidth = (statsRaw ? visibleWidth(statsRaw) + sepWidth : 0) + + visibleWidth(ctxRaw) + sepWidth + visibleWidth(modelRaw); + const pathBudget = width - rightBlockWidth - sepWidth; // -sepWidth for first separator + + let pathDisplay: string; + if (pathBudget >= 10) { + // Enough room for path + if (visibleWidth(pathRaw) <= pathBudget) { + pathDisplay = gold(pathRaw); + } else { + // Truncate from the left: …ail/path (branch) + const truncated = "…" + pathRaw.slice(-(pathBudget - 1)); + pathDisplay = gold(truncated); + } + } else { + // Very narrow — skip path entirely + pathDisplay = ""; + } + + // Context coloring + let ctxColored: string; + if (pct > 90) ctxColored = theme.fg("error", ctxRaw); + else if (pct > 70) ctxColored = theme.fg("warning", ctxRaw); + else ctxColored = theme.fg("success", ctxRaw); + + // Thinking level coloring — semaphore, hardcoded to decouple from border + const thinkingColors: Record = { + off: "\x1b[38;2;51;45;62m", // surface1 — invisible + minimal: "\x1b[38;2;67;61;78m", // surface2 — barely there + low: "\x1b[38;2;166;227;161m", // green + medium: "\x1b[38;2;249;226;175m", // yellow + high: "\x1b[38;2;250;179;135m", // peach + xhigh: "\x1b[38;2;243;139;168m", // red + }; + const thinkAnsi = thinkingColors[thinking] ?? thinkingColors.off; + let modelColored = theme.fg("accent", `⚡ ${modelName}`) + theme.fg("muted", ` (${provider})`); + if (thinking !== "off") { + modelColored += theme.fg("dim", " • ") + `${thinkAnsi}${thinking}\x1b[0m`; + } + + // Blue (#89B4FA) for token stats + const blue = (s: string) => `\x1b[38;2;137;180;250m${s}\x1b[0m`; + const statsColored = statsRaw ? blue(statsRaw) : ""; + + // Build the line + const segments: string[] = []; + if (pathDisplay) segments.push(pathDisplay); + if (statsColored) segments.push(statsColored); + segments.push(ctxColored); + segments.push(modelColored); + + const joined = segments.join(sep); + return [truncateToWidth(joined, width)]; + }, + }; + }); + }); +} diff --git a/extensions/ghostty/LICENSE b/extensions/ghostty/LICENSE new file mode 100644 index 0000000..45a1ecd --- /dev/null +++ b/extensions/ghostty/LICENSE @@ -0,0 +1,23 @@ +Derived from pi-ghostty (https://github.com/HazAT/pi-ghostty) + +MIT License + +Copyright (c) 2026 HazAT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/ghostty/ghostty.ts b/extensions/ghostty/ghostty.ts new file mode 100644 index 0000000..574138e --- /dev/null +++ b/extensions/ghostty/ghostty.ts @@ -0,0 +1,118 @@ +/** + * ghostty - Ghostty terminal title and progress bar integration. + * + * Shows a dynamic window title with project, session, and model info. + * Animates a braille spinner and pulses Ghostty's native progress bar + * while the agent is working, with a brief completion flash when done. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; + +const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +let currentModel: string | undefined; +let sessionName: string | undefined; +let isWorking = false; +let currentTool: string | undefined; +let spinnerTimer: ReturnType | undefined; +let completionTimer: ReturnType | undefined; +let frameIndex = 0; + +function ghosttyWrite(seq: string) { + try { + writeFileSync("/dev/tty", seq); + } catch { + // /dev/tty unavailable (e.g. subagent context) + } +} + +function setProgress(state: number, value?: number) { + const args = value !== undefined ? `${state};${value}` : `${state}`; + ghosttyWrite(`\x1b]9;4;${args}\x07`); +} + +function buildTitle(extra?: string): string { + const segments: string[] = ["π"]; + segments.push(path.basename(process.cwd())); + if (sessionName) segments.push(sessionName); + if (extra) segments.push(extra); + else if (currentModel) segments.push(currentModel); + return segments.join(" · "); +} + +function startSpinner(ctx: { ui: { setTitle: (title: string) => void } }) { + if (spinnerTimer) clearInterval(spinnerTimer); + if (completionTimer) { + clearTimeout(completionTimer); + completionTimer = undefined; + } + isWorking = true; + currentTool = undefined; + frameIndex = 0; + setProgress(3); + spinnerTimer = setInterval(() => { + const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]; + frameIndex++; + const extra = currentTool ?? undefined; + ctx.ui.setTitle(`${frame} ${buildTitle(extra)}`); + }, 80); +} + +function stopSpinner(ctx: { ui: { setTitle: (title: string) => void } }) { + isWorking = false; + currentTool = undefined; + if (spinnerTimer) { + clearInterval(spinnerTimer); + spinnerTimer = undefined; + } + setProgress(1, 100); + ctx.ui.setTitle(buildTitle()); + completionTimer = setTimeout(() => { + setProgress(0); + completionTimer = undefined; + }, 800); +} + +export default function (pi: ExtensionAPI) { + pi.on("session_start", async (_event, ctx) => { + if (!ctx.hasUI) return; + currentModel = ctx.model?.id; + sessionName = pi.getSessionName(); + ctx.ui.setTitle(buildTitle()); + }); + + pi.on("model_select", async (event, ctx) => { + if (!ctx.hasUI) return; + currentModel = event.model.id; + if (!isWorking) ctx.ui.setTitle(buildTitle()); + }); + + pi.on("agent_start", async (_event, ctx) => { + if (!ctx.hasUI) return; + startSpinner(ctx); + }); + + pi.on("agent_end", async (_event, ctx) => { + if (!ctx.hasUI) return; + stopSpinner(ctx); + }); + + pi.on("tool_execution_start", async (event, _ctx) => { + currentTool = event.toolName; + }); + + pi.on("tool_execution_end", async (_event, _ctx) => { + currentTool = undefined; + }); + + pi.on("session_shutdown", async (_event, ctx) => { + if (!ctx.hasUI) return; + if (spinnerTimer) clearInterval(spinnerTimer); + if (completionTimer) clearTimeout(completionTimer); + spinnerTimer = undefined; + completionTimer = undefined; + setProgress(0); + }); +} diff --git a/extensions/leader-key/favourite-models.json b/extensions/leader-key/favourite-models.json new file mode 100644 index 0000000..cb3dc4d --- /dev/null +++ b/extensions/leader-key/favourite-models.json @@ -0,0 +1,51 @@ +[ + { + "key": "a", + "label": "Antigravity Opus 4.6", + "provider": "google-antigravity", + "model": "claude-opus-4-6-thinking", + "thinking": "medium" + }, + { + "key": "s", + "label": "Antigravity Sonnet 4.5", + "provider": "google-antigravity", + "model": "claude-sonnet-4-5-thinking", + "thinking": "medium" + }, + { + "key": "d", + "label": "Anthropic Opus 4.6", + "provider": "anthropic", + "model": "claude-opus-4-6", + "thinking": "medium" + }, + { + "key": "f", + "label": "Anthropic Sonnet 4.6", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "thinking": "medium" + }, + { + "key": "j", + "label": "OpenAI GPT-5.3", + "provider": "openai", + "model": "gpt-5.3-codex", + "thinking": "medium" + }, + { + "key": "k", + "label": "Copilot Opus 4.6", + "provider": "github-copilot", + "model": "claude-opus-4.6", + "thinking": "medium" + }, + { + "key": "l", + "label": "Copilot Sonnet 4.6", + "provider": "github-copilot", + "model": "claude-sonnet-4.6", + "thinking": "medium" + } +] diff --git a/extensions/leader-key/favourite-models.ts b/extensions/leader-key/favourite-models.ts new file mode 100644 index 0000000..499ab58 --- /dev/null +++ b/extensions/leader-key/favourite-models.ts @@ -0,0 +1,218 @@ +/** + * Favourite Models Picker + * + * Quick-switch to a favourite model using a single key press (1-6, a-z). + * Favourites are configured in favourite-models.json (max 6 entries). + * + * Each entry specifies: + * - key: single character shortcut (number or letter) + * - label: display name shown in the picker + * - provider: provider name (e.g. "anthropic", "google", "openai") + * - model: model id + * - thinking: (optional) thinking level to set after switching + */ + +import type { ExtensionAPI, ExtensionContext, ThinkingLevel } from "@mariozechner/pi-coding-agent"; +import { matchesKey, parseKey, visibleWidth, truncateToWidth, Key } from "@mariozechner/pi-tui"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { ALL_THINKING_LEVELS } from "./model-switcher"; + +// ───────────────────────────────────────────────────────────────────────────── +// Config types +// ───────────────────────────────────────────────────────────────────────────── + +interface FavouriteModelEntry { + key: string; + label: string; + provider: string; + model: string; + thinking?: string; +} + +const MAX_FAVOURITES = 8; + +// ───────────────────────────────────────────────────────────────────────────── +// Load config +// ───────────────────────────────────────────────────────────────────────────── + +function loadFavourites(): FavouriteModelEntry[] { + const configPath = join(dirname(new URL(import.meta.url).pathname), "favourite-models.json"); + try { + const raw = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(raw) as FavouriteModelEntry[]; + if (!Array.isArray(parsed)) { + return []; + } + // Enforce max and validate + return parsed + .filter( + (e) => + typeof e.key === "string" && + e.key.length === 1 && + typeof e.label === "string" && + typeof e.provider === "string" && + typeof e.model === "string", + ) + .slice(0, MAX_FAVOURITES); + } catch { + return []; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Favourite Models Picker UI +// ───────────────────────────────────────────────────────────────────────────── + +export async function runFavouriteModels(pi: ExtensionAPI, ctx: ExtensionContext): Promise { + if (!ctx.hasUI) return; + + const favourites = loadFavourites(); + if (favourites.length === 0) { + ctx.ui.notify("No favourite models configured. Edit favourite-models.json", "warning"); + return; + } + + const currentModel = ctx.model; + const currentThinking = pi.getThinkingLevel(); + + const selected = await ctx.ui.custom( + (tui, theme, _kb, done) => { + let highlightedIndex = 0; + const th = theme; + + const pad = (s: string, len: number) => { + const vis = visibleWidth(s); + return s + " ".repeat(Math.max(0, len - vis)); + }; + + return { + render: (width: number) => { + const lines: string[] = []; + const maxW = Math.min(width, 80); + + const hLine = "─".repeat(maxW - 2); + const row = (content: string) => + th.fg("border", "│") + " " + pad(content, maxW - 4) + " " + th.fg("border", "│"); + + // Header + lines.push(th.fg("border", `╭${hLine}╮`)); + lines.push(row(th.fg("accent", th.bold("Favourite Models")))); + lines.push(th.fg("border", `├${hLine}┤`)); + + // Items + for (let i = 0; i < favourites.length; i++) { + const fav = favourites[i]; + const isHighlighted = i === highlightedIndex; + + // Check if this is the current model + const isCurrent = + currentModel?.provider === fav.provider && + currentModel?.id === fav.model && + (!fav.thinking || currentThinking === fav.thinking); + + const keyBadge = th.fg("warning", th.bold(`[${fav.key}]`)); + const label = isHighlighted + ? th.fg("accent", th.bold(fav.label)) + : th.fg("text", fav.label); + + const currentBadge = isCurrent ? " " + th.fg("success", "●") : ""; + + let desc = th.fg("dim", `${fav.provider}/${fav.model}`); + if (fav.thinking) { + desc += th.fg("dim", ` (${fav.thinking})`); + } + + let line = `${isHighlighted ? "> " : " "}${keyBadge} ${label}${currentBadge} ${desc}`; + lines.push(row(truncateToWidth(line, maxW - 4))); + } + + // Footer + lines.push(th.fg("border", `├${hLine}┤`)); + lines.push(row(th.fg("dim", "press key to switch | ↑↓ navigate | enter select | esc cancel"))); + lines.push(th.fg("border", `╰${hLine}╯`)); + + return lines; + }, + invalidate: () => {}, + handleInput: (data: string) => { + // Escape / Ctrl+C: cancel + if (matchesKey(data, "escape") || matchesKey(data, Key.ctrl("c"))) { + done(null); + return; + } + + // Backspace: cancel + if (matchesKey(data, "backspace")) { + done(null); + return; + } + + // Arrow keys + if (matchesKey(data, "up") || matchesKey(data, Key.ctrl("p"))) { + highlightedIndex = Math.max(0, highlightedIndex - 1); + tui.requestRender(); + return; + } + if (matchesKey(data, "down") || matchesKey(data, Key.ctrl("n"))) { + highlightedIndex = Math.min(favourites.length - 1, highlightedIndex + 1); + tui.requestRender(); + return; + } + + // Enter: select highlighted + if (matchesKey(data, "enter")) { + if (highlightedIndex >= 0 && highlightedIndex < favourites.length) { + done(favourites[highlightedIndex]); + } + return; + } + + // Direct key press — match favourite key + const parsed = parseKey(data); + const keyChar = parsed && parsed.length === 1 ? parsed.toLowerCase() : null; + const rawChar = data.length === 1 && data >= " " && data <= "~" ? data.toLowerCase() : null; + const pressedKey = keyChar || rawChar; + + if (pressedKey) { + const match = favourites.find((f) => f.key.toLowerCase() === pressedKey); + if (match) { + done(match); + } + } + }, + }; + }, + { + overlay: true, + overlayOptions: { + anchor: "center", + width: 80, + minWidth: 50, + }, + }, + ); + + if (!selected) return; + + // ── Apply the selected model ────────────────────────────────────────── + const modelInfo = ctx.modelRegistry.find(selected.provider, selected.model); + if (!modelInfo) { + ctx.ui.notify(`Model ${selected.provider}/${selected.model} not found in registry`, "error"); + return; + } + + const ok = await pi.setModel(modelInfo); + if (!ok) { + ctx.ui.notify(`No API key available for ${selected.provider}/${selected.model}`, "warning"); + return; + } + + // Apply thinking level if specified + if (selected.thinking && ALL_THINKING_LEVELS.includes(selected.thinking as ThinkingLevel)) { + pi.setThinkingLevel(selected.thinking as ThinkingLevel); + } + + const thinkingLabel = selected.thinking ? ` (thinking: ${selected.thinking})` : ""; + ctx.ui.notify(`Switched to ${selected.label}${thinkingLabel}`, "info"); +} diff --git a/extensions/leader-key/index.ts b/extensions/leader-key/index.ts new file mode 100644 index 0000000..5b9f44f --- /dev/null +++ b/extensions/leader-key/index.ts @@ -0,0 +1,616 @@ +/** + * Leader Key Extension + * + * Press Ctrl+Space to open a floating command palette showing all available + * actions organised into groups (like Vim's which-key or Emacs' leader key). + * + * Each group has a single-character chord key. Press the chord to see the + * group's actions, then press the action key to execute. + * + * Navigation: + * - Chord keys shown in the palette (e.g. "s" for Session, "m" for Model) + * - Backspace / Escape to go back or close + * - Direct key press executes the action immediately + * + * The palette auto-discovers extension commands and merges them with + * built-in actions (session, model, etc.). + */ + +import type { + ExtensionAPI, + ExtensionContext, + Theme, + ThinkingLevel, +} from "@mariozechner/pi-coding-agent"; +import { matchesKey, parseKey, visibleWidth, truncateToWidth, Key } from "@mariozechner/pi-tui"; +import { spawnSync } from "node:child_process"; +import { runModelSwitcher, runThinkingPicker, searchableSelect } from "./model-switcher"; +import { runFavouriteModels } from "./favourite-models"; +import { runSessionSwitch } from "../session-switch/index"; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +interface ActionItem { + key: string; // single character shortcut + label: string; + description?: string; + action: (ctx: ExtensionContext) => void | Promise; +} + +interface ActionGroup { + key: string; // chord key to open this group + label: string; + items: ActionItem[]; +} + +/** Top-level entry: either a group (chord → submenu) or a direct action */ +type TopLevelEntry = + | { type: "group"; group: ActionGroup } + | { type: "action"; key: string; label: string; description?: string; action: (ctx: ExtensionContext) => void | Promise }; + +// ───────────────────────────────────────────────────────────────────────────── +// Build top-level entries +// ───────────────────────────────────────────────────────────────────────────── + +// ───────────────────────────────────────────────────────────────────────────── +// External app launchers +// ───────────────────────────────────────────────────────────────────────────── + +async function runExternalApp(ctx: ExtensionContext, command: string, label: string) { + if (!ctx.hasUI) { + ctx.ui.notify(`${label} requires an interactive terminal`, "error"); + return; + } + + await ctx.ui.custom((tui, _theme, _kb, done) => { + // Suspend pi's TUI to release the terminal + tui.stop(); + + // Clear screen before handing off + process.stdout.write("\x1b[2J\x1b[H"); + + // Run the command with full terminal access + const shell = process.env.SHELL || "/bin/sh"; + const result = spawnSync(shell, ["-c", command], { + stdio: "inherit", + cwd: ctx.cwd, + env: process.env, + }); + + // Restore pi's TUI + tui.start(); + tui.requestRender(true); + + done(result.status); + + // Return empty component (immediately disposed) + return { render: () => [], invalidate: () => {} }; + }); +} + +async function runLazygit(ctx: ExtensionContext) { + await runExternalApp(ctx, "lazygit", "lazygit"); +} + +async function runLazyvim(ctx: ExtensionContext) { + await runExternalApp(ctx, "nvim", "lazyvim"); +} + +async function runVscode(ctx: ExtensionContext) { + spawnSync(process.env.SHELL || "/bin/sh", ["-c", "code ."], { + stdio: "inherit", + cwd: ctx.cwd, + env: process.env, + }); + ctx.ui.notify("Opening VS Code…", "info"); +} + +function buildEntries(pi: ExtensionAPI, ctx: ExtensionContext): TopLevelEntry[] { + const entries: TopLevelEntry[] = []; + + // ── Session ───────────────────────────────────────────────────────── + entries.push({ + type: "group", + group: { + key: "s", + label: "Session", + items: [ + { + key: "n", + label: "New session", + description: "start fresh", + action: (ctx) => { + ctx.ui.setEditorText("/new"); + setTimeout(() => process.stdin.emit("data", "\r"), 0); + }, + }, + { + key: "r", + label: "Resume session", + description: "/resume", + action: (ctx) => { + ctx.ui.setEditorText("/resume"); + setTimeout(() => process.stdin.emit("data", "\r"), 0); + }, + }, + { + key: "s", + label: "Switch session", + description: "split panel picker", + action: (ctx) => runSessionSwitch(pi, ctx), + }, + { + key: "t", + label: "Session tree", + description: "/tree", + action: (ctx) => { + ctx.ui.setEditorText("/tree"); + setTimeout(() => process.stdin.emit("data", "\r"), 0); + }, + }, + { + key: "f", + label: "Fork session", + description: "/fork", + action: (ctx) => { + ctx.ui.setEditorText("/fork"); + setTimeout(() => process.stdin.emit("data", "\r"), 0); + }, + }, + { + key: "c", + label: "Compact context", + description: "compact now", + action: (ctx) => { + ctx.compact({}); + ctx.ui.notify("Compaction started", "info"); + }, + }, + ], + }, + }); + + // ── Model (direct action → opens model switcher wizard) ───────────── + const currentModel = ctx.model; + entries.push({ + type: "action", + key: "m", + label: "Model", + description: currentModel ? `${currentModel.provider}/${currentModel.id}` : "switch model", + action: (ctx) => runModelSwitcher(pi, ctx), + }); + + // ── Favourite models (direct action → quick-switch picker) ────────── + entries.push({ + type: "action", + key: "f", + label: "Favourites", + description: "quick-switch favourite models", + action: (ctx) => runFavouriteModels(pi, ctx), + }); + + // ── Thinking (direct action → opens thinking level picker) ────────── + const currentThinking = pi.getThinkingLevel(); + entries.push({ + type: "action", + key: "t", + label: "Thinking", + description: `current: ${currentThinking}`, + action: (ctx) => runThinkingPicker(pi, ctx), + }); + + // ── Git: lazygit (direct action) ──────────────────────────────────── + entries.push({ + type: "action", + key: "g", + label: "Lazygit", + description: "open lazygit in current folder", + action: (ctx) => runLazygit(ctx), + }); + + // ── Open (external editors / apps) ─────────────────────────────────── + entries.push({ + type: "group", + group: { + key: "o", + label: "Open", + items: [ + { + key: "v", + label: "LazyVim", + description: "open lazyvim in current folder", + action: (ctx) => runLazyvim(ctx), + }, + { + key: "c", + label: "VS Code", + description: "open vscode in current folder", + action: (ctx) => runVscode(ctx), + }, + ], + }, + }); + + // ── Extension commands (auto-discovered, searchable picker) ───────── + const commands = pi.getCommands(); + const extCommands = commands.filter((c) => c.source === "extension"); + + // Filter out commands that are already represented in built-in entries + const builtinCommandNames = new Set([ + "new", "resume", "tree", "fork", "compact", + "model", "thinking", "tools", "reload", + "switch", "lk", "leader-key", + ]); + + const customCommands = extCommands.filter((c) => !builtinCommandNames.has(c.name)); + + if (customCommands.length > 0) { + entries.push({ + type: "action", + key: "e", + label: "Extensions", + description: `${customCommands.length} command${customCommands.length !== 1 ? "s" : ""}`, + action: async (ctx) => { + const items = customCommands.map((cmd) => ({ + value: cmd.name, + label: cmd.name, + description: cmd.description || "extension", + })); + + const selected = await searchableSelect( + ctx, + "Select Extension Command", + items, + ); + + if (selected) { + pi.sendUserMessage(`/${selected}`); + } + }, + }); + } + + // ── Skills (direct action → searchable picker) ───────────────────── + const skillCommands = commands.filter((c) => c.source === "skill"); + + if (skillCommands.length > 0) { + entries.push({ + type: "action", + key: "k", + label: "Skills", + description: `${skillCommands.length} skill${skillCommands.length !== 1 ? "s" : ""}`, + action: async (ctx) => { + const items = skillCommands.map((cmd) => ({ + value: cmd.name, + label: cmd.name, + description: cmd.description || "skill", + })); + + const selected = await searchableSelect( + ctx, + "Select Skill", + items, + ); + + if (selected) { + ctx.ui.setEditorText(`/${selected} `); + ctx.ui.notify(`Type your prompt after /${selected}`, "info"); + } + }, + }); + } + + // ── Exit ───────────────────────────────────────────────────────────── + entries.push({ + type: "action", + key: "x", + label: "Exit", + description: "quit pi", + action: (ctx) => { + ctx.ui.setEditorText("/quit"); + setTimeout(() => process.stdin.emit("data", "\r"), 0); + }, + }); + + return entries; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Overlay component +// ───────────────────────────────────────────────────────────────────────────── + +type View = { type: "root" } | { type: "group"; group: ActionGroup }; + +class LeaderKeyOverlay { + private view: View = { type: "root" }; + private entries: TopLevelEntry[]; + private theme: Theme; + private done: (result: ActionItem | null) => void; + private highlightedIndex = 0; + + constructor( + entries: TopLevelEntry[], + theme: Theme, + done: (result: ActionItem | null) => void, + ) { + this.entries = entries; + this.theme = theme; + this.done = done; + } + + private get currentItems(): Array<{ key: string; label: string; description?: string }> { + if (this.view.type === "root") { + return this.entries.map((e) => { + if (e.type === "group") { + return { + key: e.group.key, + label: e.group.label, + description: `${e.group.items.length} action${e.group.items.length !== 1 ? "s" : ""}`, + }; + } + return { + key: e.key, + label: e.label, + description: e.description, + }; + }); + } + return this.view.group.items; + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, Key.ctrl("c"))) { + if (this.view.type === "group") { + this.view = { type: "root" }; + this.highlightedIndex = 0; + } else { + this.done(null); + } + return; + } + + if (matchesKey(data, "backspace")) { + if (this.view.type === "group") { + this.view = { type: "root" }; + this.highlightedIndex = 0; + } else { + this.done(null); + } + return; + } + + // Arrow keys for highlighting + if (matchesKey(data, "up")) { + this.highlightedIndex = Math.max(0, this.highlightedIndex - 1); + return; + } + if (matchesKey(data, "down")) { + const items = this.currentItems; + this.highlightedIndex = Math.min(items.length - 1, this.highlightedIndex + 1); + return; + } + + // Enter to select highlighted item + if (matchesKey(data, "enter") || matchesKey(data, "return")) { + const items = this.currentItems; + if (this.highlightedIndex >= 0 && this.highlightedIndex < items.length) { + const item = items[this.highlightedIndex]; + if (this.view.type === "root") { + this.handleRootSelection(item.key); + } else { + const action = this.view.group.items.find((a) => a.key === item.key); + if (action) { + this.done(action); + } + } + } + return; + } + + // Direct key press — use parseKey to handle both raw chars and Kitty protocol sequences + const parsed = parseKey(data); + if (parsed && parsed.length === 1 && parsed >= "a" && parsed <= "z") { + const key = parsed.toLowerCase(); + + if (this.view.type === "root") { + this.handleRootSelection(key); + } else { + const action = this.view.group.items.find((a) => a.key === key); + if (action) { + this.done(action); + } + } + } else if (data.length === 1 && data >= " " && data <= "~") { + // Fallback for raw printable characters (legacy terminals) + const key = data.toLowerCase(); + + if (this.view.type === "root") { + this.handleRootSelection(key); + } else { + const action = this.view.group.items.find((a) => a.key === key); + if (action) { + this.done(action); + } + } + } + } + + private handleRootSelection(key: string): void { + const entry = this.entries.find((e) => { + if (e.type === "group") return e.group.key === key; + return e.key === key; + }); + if (!entry) return; + + if (entry.type === "group") { + this.view = { type: "group", group: entry.group }; + this.highlightedIndex = 0; + } else { + // Direct action — wrap it as an ActionItem and fire + this.done({ + key: entry.key, + label: entry.label, + description: entry.description, + action: entry.action, + }); + } + } + + render(width: number): string[] { + const th = this.theme; + const lines: string[] = []; + const maxW = Math.min(width, 80); + + const pad = (s: string, len: number) => { + const vis = visibleWidth(s); + return s + " ".repeat(Math.max(0, len - vis)); + }; + + const hLine = "─".repeat(maxW - 2); + const row = (content: string) => + th.fg("border", "│") + " " + pad(content, maxW - 4) + " " + th.fg("border", "│"); + + // Header + lines.push(th.fg("border", `╭${hLine}╮`)); + + if (this.view.type === "root") { + lines.push(row(th.fg("accent", th.bold("Leader Key")))); + } else { + const g = this.view.group; + const breadcrumb = th.fg("dim", "< ") + th.fg("accent", th.bold(g.label)); + lines.push(row(breadcrumb)); + } + + lines.push(th.fg("border", `├${hLine}┤`)); + + // Items + const items = this.currentItems; + if (items.length === 0) { + lines.push(row(th.fg("muted", " (no items)"))); + } else { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const isHighlighted = i === this.highlightedIndex; + + const keyBadge = th.fg("warning", th.bold(`[${item.key}]`)); + const label = isHighlighted + ? th.fg("accent", th.bold(item.label)) + : th.fg("text", item.label); + + // Show a chevron for groups in root view + let suffix = ""; + if (this.view.type === "root") { + const entry = this.entries.find((e) => { + if (e.type === "group") return e.group.key === item.key; + return e.key === item.key; + }); + if (entry?.type === "group") { + suffix = " " + th.fg("dim", ">"); + } + } + + let line = `${isHighlighted ? "> " : " "}${keyBadge} ${label}${suffix}`; + + if (item.description) { + line += " " + th.fg("dim", item.description); + } + + lines.push(row(truncateToWidth(line, maxW - 4))); + } + } + + // Footer + lines.push(th.fg("border", `├${hLine}┤`)); + + if (this.view.type === "root") { + lines.push( + row(th.fg("dim", "press key to select | esc close")), + ); + } else { + lines.push( + row(th.fg("dim", "press key to run | bksp back | esc close")), + ); + } + + lines.push(th.fg("border", `╰${hLine}╯`)); + + return lines; + } + + invalidate(): void {} +} + +// ───────────────────────────────────────────────────────────────────────────── +// Extension +// ───────────────────────────────────────────────────────────────────────────── + +export default function leaderKeyExtension(pi: ExtensionAPI) { + async function openLeaderKey(ctx: ExtensionContext) { + if (!ctx.hasUI) return; + + const entries = buildEntries(pi, ctx); + + const selected = await ctx.ui.custom( + (tui, theme, _kb, done) => { + const overlay = new LeaderKeyOverlay(entries, theme, done); + return { + render: (w: number) => overlay.render(w), + invalidate: () => overlay.invalidate(), + handleInput: (data: string) => { + overlay.handleInput(data); + tui.requestRender(); + }, + }; + }, + { + overlay: true, + overlayOptions: { + anchor: "center", + width: 80, + minWidth: 50, + }, + }, + ); + + if (selected) { + try { + await selected.action(ctx); + } catch (err) { + ctx.ui.notify(`Action failed: ${err}`, "error"); + } + } + } + + // ── Model-switcher commands (previously standalone extension) ──────── + pi.registerCommand("switch", { + description: "Switch model (provider → model → thinking level)", + handler: async (_args, ctx) => { + await runModelSwitcher(pi, ctx); + }, + }); + + pi.registerShortcut(Key.ctrlShift("m"), { + description: "Switch model (provider → model → thinking level)", + handler: async (ctx) => { + await runModelSwitcher(pi, ctx); + }, + }); + + // Register as a command + pi.registerCommand("lk", { + description: "Open Leader Key palette", + handler: async (_args, ctx) => { + await openLeaderKey(ctx); + }, + }); + + // Register shortcut: Ctrl+Space + pi.registerShortcut(Key.ctrl("space"), { + description: "Open Leader Key", + handler: async (ctx) => { + await openLeaderKey(ctx); + }, + }); +} diff --git a/extensions/leader-key/model-switcher.ts b/extensions/leader-key/model-switcher.ts new file mode 100644 index 0000000..b67e5d8 --- /dev/null +++ b/extensions/leader-key/model-switcher.ts @@ -0,0 +1,419 @@ +/** + * Model Switcher (embedded in leader-key) + * + * A multi-step model selector with searchable lists: + * 1. Pick a provider (searchable) + * 2. Pick a model from that provider (searchable) + * 3. Pick a thinking level (searchable) + * + * Exported for use by the leader-key extension. + */ + +import type { ExtensionAPI, ExtensionContext, ThinkingLevel } from "@mariozechner/pi-coding-agent"; +import { SettingsManager } from "@mariozechner/pi-coding-agent"; +import { fuzzyFilter, Key, matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui"; + +export const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers — only enabled & available providers & models +// ───────────────────────────────────────────────────────────────────────────── + +interface ProviderInfo { + name: string; + modelCount: number; +} + +/** + * Get the set of enabled model identifiers from settings. + * Returns undefined when there is no filter (all models are enabled). + */ +function getEnabledModelSet(): Set | undefined { + const sm = SettingsManager.create(); + const patterns = sm.getEnabledModels(); + if (!patterns || patterns.length === 0) return undefined; + // enabledModels entries are "provider/modelId" exact strings (or globs, but + // for our purposes exact membership check covers the common case). + return new Set(patterns.map((p) => p.toLowerCase())); +} + +/** + * Check whether a model matches the enabledModels allowlist. + * Supports exact "provider/modelId" entries and simple glob "*" patterns. + */ +function isModelEnabled(provider: string, modelId: string, enabled: Set | undefined): boolean { + if (!enabled) return true; // no filter → everything enabled + const key = `${provider}/${modelId}`.toLowerCase(); + // Exact match first + if (enabled.has(key)) return true; + // Simple glob matching (supports trailing *, e.g. "anthropic/*") + for (const pattern of enabled) { + if (pattern.includes("*") || pattern.includes("?")) { + const regex = new RegExp( + "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$", + ); + if (regex.test(key)) return true; + } + } + return false; +} + +function getAvailableEnabledModels(ctx: ExtensionContext) { + const enabled = getEnabledModelSet(); + return ctx.modelRegistry + .getAvailable() + .filter((m) => isModelEnabled(m.provider, m.id, enabled)); +} + +export function getProviders(ctx: ExtensionContext): ProviderInfo[] { + const models = getAvailableEnabledModels(ctx); + const providerMap = new Map(); + + for (const model of models) { + providerMap.set(model.provider, (providerMap.get(model.provider) ?? 0) + 1); + } + + return Array.from(providerMap.entries()) + .map(([name, count]) => ({ name, modelCount: count })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function getModelsForProvider(ctx: ExtensionContext, provider: string) { + return getAvailableEnabledModels(ctx) + .filter((m) => m.provider === provider) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Searchable select UI — styled to match leader-key overlay +// ───────────────────────────────────────────────────────────────────────────── + +interface SearchableItem { + value: string; + label: string; + description?: string; +} + +const MAX_VISIBLE = 15; + +export async function searchableSelect( + ctx: ExtensionContext, + title: string, + items: SearchableItem[], + helpText?: string, +): Promise { + return ctx.ui.custom((tui, theme, _kb, done) => { + let searchText = ""; + let filteredItems = [...items]; + let highlightedIndex = 0; + let scrollOffset = 0; + + const th = theme; + + const pad = (s: string, len: number) => { + const vis = visibleWidth(s); + return s + " ".repeat(Math.max(0, len - vis)); + }; + + const applyFilter = () => { + if (searchText === "") { + filteredItems = [...items]; + } else { + filteredItems = fuzzyFilter(items, searchText, (item) => `${item.label} ${item.value}`); + } + highlightedIndex = 0; + scrollOffset = 0; + }; + + const ensureVisible = () => { + if (highlightedIndex < scrollOffset) { + scrollOffset = highlightedIndex; + } else if (highlightedIndex >= scrollOffset + MAX_VISIBLE) { + scrollOffset = highlightedIndex - MAX_VISIBLE + 1; + } + }; + + return { + render: (width: number) => { + const lines: string[] = []; + const maxW = Math.min(width, 80); + + const hLine = "─".repeat(maxW - 2); + const row = (content: string) => + th.fg("border", "│") + " " + pad(content, maxW - 4) + " " + th.fg("border", "│"); + + // Header + lines.push(th.fg("border", `╭${hLine}╮`)); + lines.push(row(th.fg("accent", th.bold(title)))); + + // Search indicator + if (searchText.length > 0) { + lines.push(row( + th.fg("muted", "search: ") + th.fg("accent", searchText) + th.fg("dim", "▏"), + )); + } + + lines.push(th.fg("border", `├${hLine}┤`)); + + // Items + if (filteredItems.length === 0) { + lines.push(row(th.fg("warning", " no matches"))); + } else { + const visibleEnd = Math.min(scrollOffset + MAX_VISIBLE, filteredItems.length); + + if (scrollOffset > 0) { + lines.push(row(th.fg("dim", ` ↑ ${scrollOffset} more`))); + } + + for (let i = scrollOffset; i < visibleEnd; i++) { + const item = filteredItems[i]; + const isHighlighted = i === highlightedIndex; + + const label = isHighlighted + ? th.fg("accent", th.bold(item.label)) + : th.fg("text", item.label); + + let line = `${isHighlighted ? "> " : " "}${label}`; + + if (item.description) { + line += " " + th.fg("dim", item.description); + } + + lines.push(row(truncateToWidth(line, maxW - 4))); + } + + const remaining = filteredItems.length - visibleEnd; + if (remaining > 0) { + lines.push(row(th.fg("dim", ` ↓ ${remaining} more`))); + } + } + + // Footer + lines.push(th.fg("border", `├${hLine}┤`)); + const hint = helpText ?? "type to search • ↑↓ navigate • enter select • esc cancel"; + lines.push(row(th.fg("dim", hint))); + lines.push(th.fg("border", `╰${hLine}╯`)); + + return lines; + }, + invalidate: () => {}, + handleInput: (data: string) => { + // Escape / Ctrl+C: cancel + if (matchesKey(data, "escape") || matchesKey(data, Key.ctrl("c"))) { + done(null); + return; + } + + // Backspace: trim search or cancel if empty + if (matchesKey(data, "backspace")) { + if (searchText.length > 0) { + searchText = searchText.slice(0, -1); + applyFilter(); + tui.requestRender(); + } + return; + } + + // Navigation + if (matchesKey(data, "up") || matchesKey(data, Key.ctrl("p"))) { + highlightedIndex = Math.max(0, highlightedIndex - 1); + ensureVisible(); + tui.requestRender(); + return; + } + if (matchesKey(data, "down") || matchesKey(data, Key.ctrl("n"))) { + highlightedIndex = Math.min(filteredItems.length - 1, highlightedIndex + 1); + ensureVisible(); + tui.requestRender(); + return; + } + + // Enter: select highlighted + if (matchesKey(data, "enter")) { + if (filteredItems.length > 0 && highlightedIndex < filteredItems.length) { + done(filteredItems[highlightedIndex].value as T); + } + return; + } + + // Printable characters: search + if (data.length === 1 && data >= " " && data <= "~") { + searchText += data; + applyFilter(); + tui.requestRender(); + return; + } + }, + }; + }, { + overlay: true, + overlayOptions: { + anchor: "center", + width: 80, + minWidth: 50, + }, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Multi-step flow +// ───────────────────────────────────────────────────────────────────────────── + +export async function runModelSwitcher(pi: ExtensionAPI, ctx: ExtensionContext): Promise { + if (!ctx.hasUI) return; + + // ── Step 1: Pick provider ────────────────────────────────────────────── + const providers = getProviders(ctx); + if (providers.length === 0) { + ctx.ui.notify("No providers available", "warning"); + return; + } + + const currentProvider = ctx.model?.provider; + + const providerItems: SearchableItem[] = providers.map((p) => { + const isCurrent = p.name === currentProvider; + const badge = isCurrent ? " (current)" : ""; + return { + value: p.name, + label: `${p.name}${badge}`, + description: `${p.modelCount} model${p.modelCount !== 1 ? "s" : ""}`, + }; + }); + + const selectedProvider = await searchableSelect( + ctx, + "Select Provider", + providerItems, + ); + if (!selectedProvider) return; + + // ── Step 2: Pick model from provider ────────────────────────────────── + const models = getModelsForProvider(ctx, selectedProvider); + if (models.length === 0) { + ctx.ui.notify(`No models found for provider "${selectedProvider}"`, "warning"); + return; + } + + const currentModelId = ctx.model?.id; + + const modelItems: SearchableItem[] = models.map((model) => { + const isCurrent = model.provider === currentProvider && model.id === currentModelId; + const badge = isCurrent ? " (current)" : ""; + const features: string[] = []; + if (model.reasoning) features.push("reasoning"); + if (model.input.includes("image")) features.push("vision"); + const desc = features.length > 0 ? features.join(", ") : ""; + + return { + value: model.id, + label: `${model.name}${badge}`, + description: desc, + }; + }); + + const selectedModelId = await searchableSelect( + ctx, + `Select Model (${selectedProvider})`, + modelItems, + ); + if (!selectedModelId) return; + + // ── Step 3: Pick thinking level ─────────────────────────────────────── + const selectedModel = ctx.modelRegistry.find(selectedProvider, selectedModelId); + if (!selectedModel) { + ctx.ui.notify(`Model ${selectedProvider}/${selectedModelId} not found`, "error"); + return; + } + + const supportsReasoning = selectedModel.reasoning; + let selectedThinking: ThinkingLevel = pi.getThinkingLevel(); + + if (supportsReasoning) { + const currentThinking = pi.getThinkingLevel(); + + const thinkingItems: SearchableItem[] = ALL_THINKING_LEVELS.map((level) => { + const isCurrent = level === currentThinking; + return { + value: level, + label: isCurrent ? `${level} (current)` : level, + description: getThinkingDescription(level), + }; + }); + + const thinkingChoice = await searchableSelect( + ctx, + `Thinking Level (${selectedModel.name})`, + thinkingItems, + "type to search • ↑↓ navigate • enter select • esc cancel", + ); + + if (!thinkingChoice) return; + selectedThinking = thinkingChoice; + } + + // ── Apply ───────────────────────────────────────────────────────────── + const ok = await pi.setModel(selectedModel); + if (!ok) { + ctx.ui.notify(`No API key available for ${selectedProvider}/${selectedModelId}`, "warning"); + return; + } + + if (supportsReasoning) { + pi.setThinkingLevel(selectedThinking); + } + + ctx.ui.notify( + `Switched to ${selectedModel.name}${supportsReasoning ? ` (thinking: ${selectedThinking})` : ""}`, + "info", + ); +} + +export function getThinkingDescription(level: ThinkingLevel): string { + switch (level) { + case "off": + return "No extended thinking"; + case "minimal": + return "Minimal reasoning effort"; + case "low": + return "Low reasoning effort"; + case "medium": + return "Moderate reasoning effort"; + case "high": + return "High reasoning effort"; + case "xhigh": + return "Maximum reasoning effort"; + default: + return ""; + } +} + +/** + * Interactive thinking level picker — opens a searchable select list of + * all thinking levels and applies the chosen one immediately. + */ +export async function runThinkingPicker(pi: ExtensionAPI, ctx: ExtensionContext): Promise { + if (!ctx.hasUI) return; + + const currentThinking = pi.getThinkingLevel(); + + const thinkingItems: SearchableItem[] = ALL_THINKING_LEVELS.map((level) => { + const isCurrent = level === currentThinking; + return { + value: level, + label: isCurrent ? `${level} (current)` : level, + description: getThinkingDescription(level), + }; + }); + + const choice = await searchableSelect( + ctx, + "Select Thinking Level", + thinkingItems, + ); + + if (!choice) return; + + pi.setThinkingLevel(choice); + ctx.ui.notify(`Thinking: ${choice}`, "info"); +} diff --git a/extensions/model-switcher/index.ts b/extensions/model-switcher/index.ts new file mode 100644 index 0000000..71c97bf --- /dev/null +++ b/extensions/model-switcher/index.ts @@ -0,0 +1,380 @@ +/** + * Model Switcher Extension + * + * A multi-step model selector with searchable lists: + * 1. Pick a provider (searchable) + * 2. Pick a model from that provider (searchable) + * 3. Pick a thinking level (searchable) + * + * Triggered via `/switch` command or Ctrl+Shift+M shortcut. + */ + +import type { ExtensionAPI, ExtensionContext, ThinkingLevel } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder, SettingsManager } from "@mariozechner/pi-coding-agent"; +import { Container, fuzzyFilter, Key, matchesKey, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; + +const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers — only enabled & available providers & models +// ───────────────────────────────────────────────────────────────────────────── + +interface ProviderInfo { + name: string; + modelCount: number; +} + +/** + * Get the set of enabled model identifiers from settings. + * Returns undefined when there is no filter (all models are enabled). + */ +function getEnabledModelSet(): Set | undefined { + const sm = SettingsManager.create(); + const patterns = sm.getEnabledModels(); + if (!patterns || patterns.length === 0) return undefined; + // enabledModels entries are "provider/modelId" exact strings (or globs, but + // for our purposes exact membership check covers the common case). + return new Set(patterns.map((p) => p.toLowerCase())); +} + +/** + * Check whether a model matches the enabledModels allowlist. + * Supports exact "provider/modelId" entries and simple glob "*" patterns. + */ +function isModelEnabled(provider: string, modelId: string, enabled: Set | undefined): boolean { + if (!enabled) return true; // no filter → everything enabled + const key = `${provider}/${modelId}`.toLowerCase(); + // Exact match first + if (enabled.has(key)) return true; + // Simple glob matching (supports trailing *, e.g. "anthropic/*") + for (const pattern of enabled) { + if (pattern.includes("*") || pattern.includes("?")) { + const regex = new RegExp( + "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$", + ); + if (regex.test(key)) return true; + } + } + return false; +} + +function getAvailableEnabledModels(ctx: ExtensionContext) { + const enabled = getEnabledModelSet(); + return ctx.modelRegistry + .getAvailable() + .filter((m) => isModelEnabled(m.provider, m.id, enabled)); +} + +function getProviders(ctx: ExtensionContext): ProviderInfo[] { + const models = getAvailableEnabledModels(ctx); + const providerMap = new Map(); + + for (const model of models) { + providerMap.set(model.provider, (providerMap.get(model.provider) ?? 0) + 1); + } + + return Array.from(providerMap.entries()) + .map(([name, count]) => ({ name, modelCount: count })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function getModelsForProvider(ctx: ExtensionContext, provider: string) { + return getAvailableEnabledModels(ctx) + .filter((m) => m.provider === provider) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Searchable select UI (reusable) +// ───────────────────────────────────────────────────────────────────────────── + +async function searchableSelect( + ctx: ExtensionContext, + title: string, + items: SelectItem[], + helpText?: string, +): Promise { + return ctx.ui.custom((tui, theme, _kb, done) => { + let searchText = ""; + + const container = new Container(); + + // Top border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + // Title + container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0)); + + // Search indicator (shown when user starts typing) + const searchDisplay = new Text("", 1, 0); + container.addChild(searchDisplay); + + // SelectList + const selectList = new SelectList(items, Math.min(items.length, 15), { + selectedPrefix: (text: string) => theme.fg("accent", text), + selectedText: (text: string) => theme.fg("accent", text), + description: (text: string) => theme.fg("muted", text), + scrollInfo: (text: string) => theme.fg("dim", text), + noMatch: (text: string) => theme.fg("warning", text), + }); + + selectList.onSelect = (item: SelectItem) => done(item.value as T); + selectList.onCancel = () => done(null); + + container.addChild(selectList); + + // Help text + const hint = helpText ?? "type to search • ↑↓ navigate • enter select • esc cancel"; + container.addChild(new Text(theme.fg("dim", hint), 1, 0)); + + // Bottom border + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + // Apply fuzzy filter to the select list. + // We bypass selectList.setFilter() (which only does startsWith on value) + // and instead use fuzzyFilter for much better matching, then set the + // internal filteredItems/selectedIndex directly. + const applyFuzzyFilter = (query: string) => { + const sl = selectList as any; + if (query === "") { + sl.filteredItems = items; + } else { + sl.filteredItems = fuzzyFilter(items, query, (item) => `${item.label} ${item.value}`); + } + sl.selectedIndex = 0; + }; + + const updateSearchDisplay = () => { + if (searchText.length > 0) { + searchDisplay.setText(theme.fg("muted", " search: ") + theme.fg("accent", searchText) + theme.fg("dim", "▏")); + } else { + searchDisplay.setText(""); + } + }; + + return { + render: (w: number) => container.render(w), + invalidate: () => container.invalidate(), + handleInput: (data: string) => { + // Backspace: trim search text + if (matchesKey(data, "backspace")) { + if (searchText.length > 0) { + searchText = searchText.slice(0, -1); + applyFuzzyFilter(searchText); + updateSearchDisplay(); + tui.requestRender(); + } + return; + } + + // Let SelectList handle navigation keys (up/down/enter/escape) + if (matchesKey(data, "up") || matchesKey(data, "down") || + matchesKey(data, "enter") || matchesKey(data, "escape") || + matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrl("n")) || + matchesKey(data, Key.ctrl("p"))) { + selectList.handleInput(data); + tui.requestRender(); + return; + } + + // Printable characters: append to search + if (data.length === 1 && data >= " " && data <= "~") { + searchText += data; + applyFuzzyFilter(searchText); + updateSearchDisplay(); + tui.requestRender(); + return; + } + + // Fallback: forward to selectList + selectList.handleInput(data); + tui.requestRender(); + }, + }; + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Multi-step flow +// ───────────────────────────────────────────────────────────────────────────── + +async function runModelSwitcher(pi: ExtensionAPI, ctx: ExtensionContext): Promise { + if (!ctx.hasUI) return; + + // ── Step 1: Pick provider ────────────────────────────────────────────── + const providers = getProviders(ctx); + if (providers.length === 0) { + ctx.ui.notify("No providers available", "warning"); + return; + } + + const currentProvider = ctx.model?.provider; + + const providerItems: SelectItem[] = providers.map((p) => { + const isCurrent = p.name === currentProvider; + const badge = isCurrent ? " (current)" : ""; + return { + value: p.name, + label: `${p.name}${badge}`, + description: `${p.modelCount} model${p.modelCount !== 1 ? "s" : ""}`, + }; + }); + + const selectedProvider = await searchableSelect( + ctx, + "Select Provider", + providerItems, + ); + if (!selectedProvider) return; + + // ── Step 2: Pick model from provider ────────────────────────────────── + const models = getModelsForProvider(ctx, selectedProvider); + if (models.length === 0) { + ctx.ui.notify(`No models found for provider "${selectedProvider}"`, "warning"); + return; + } + + const currentModelId = ctx.model?.id; + + const modelItems: SelectItem[] = models.map((model) => { + const isCurrent = model.provider === currentProvider && model.id === currentModelId; + const badge = isCurrent ? " (current)" : ""; + const features: string[] = []; + if (model.reasoning) features.push("reasoning"); + if (model.input.includes("image")) features.push("vision"); + const desc = features.length > 0 ? features.join(", ") : ""; + + return { + value: model.id, + label: `${model.name}${badge}`, + description: desc, + }; + }); + + const selectedModelId = await searchableSelect( + ctx, + `Select Model (${selectedProvider})`, + modelItems, + ); + if (!selectedModelId) return; + + // ── Step 3: Pick thinking level ─────────────────────────────────────── + const selectedModel = ctx.modelRegistry.find(selectedProvider, selectedModelId); + if (!selectedModel) { + ctx.ui.notify(`Model ${selectedProvider}/${selectedModelId} not found`, "error"); + return; + } + + const supportsReasoning = selectedModel.reasoning; + let selectedThinking: ThinkingLevel = pi.getThinkingLevel(); + + if (supportsReasoning) { + const currentThinking = pi.getThinkingLevel(); + + const thinkingItems: SelectItem[] = ALL_THINKING_LEVELS.map((level) => { + const isCurrent = level === currentThinking; + return { + value: level, + label: isCurrent ? `${level} (current)` : level, + description: getThinkingDescription(level), + }; + }); + + const thinkingChoice = await searchableSelect( + ctx, + `Thinking Level (${selectedModel.name})`, + thinkingItems, + "type to search • ↑↓ navigate • enter select • esc cancel", + ); + + if (!thinkingChoice) return; + selectedThinking = thinkingChoice; + } + + // ── Apply ───────────────────────────────────────────────────────────── + const ok = await pi.setModel(selectedModel); + if (!ok) { + ctx.ui.notify(`No API key available for ${selectedProvider}/${selectedModelId}`, "warning"); + return; + } + + if (supportsReasoning) { + pi.setThinkingLevel(selectedThinking); + } + + ctx.ui.notify( + `Switched to ${selectedModel.name}${supportsReasoning ? ` (thinking: ${selectedThinking})` : ""}`, + "info", + ); +} + +function getThinkingDescription(level: ThinkingLevel): string { + switch (level) { + case "off": + return "No extended thinking"; + case "minimal": + return "Minimal reasoning effort"; + case "low": + return "Low reasoning effort"; + case "medium": + return "Moderate reasoning effort"; + case "high": + return "High reasoning effort"; + case "xhigh": + return "Maximum reasoning effort"; + default: + return ""; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Extension Export +// ───────────────────────────────────────────────────────────────────────────── + +export { runModelSwitcher, runThinkingPicker }; + +/** + * Interactive thinking level picker — opens a searchable select list of + * all thinking levels and applies the chosen one immediately. + */ +async function runThinkingPicker(pi: ExtensionAPI, ctx: ExtensionContext): Promise { + if (!ctx.hasUI) return; + + const currentThinking = pi.getThinkingLevel(); + + const thinkingItems: SelectItem[] = ALL_THINKING_LEVELS.map((level) => { + const isCurrent = level === currentThinking; + return { + value: level, + label: isCurrent ? `${level} (current)` : level, + description: getThinkingDescription(level), + }; + }); + + const choice = await searchableSelect( + ctx, + "Select Thinking Level", + thinkingItems, + ); + + if (!choice) return; + + pi.setThinkingLevel(choice); + ctx.ui.notify(`Thinking: ${choice}`, "info"); +} + +export default function (pi: ExtensionAPI) { + pi.registerCommand("switch", { + description: "Switch model (provider → model → thinking level)", + handler: async (_args, ctx) => { + await runModelSwitcher(pi, ctx); + }, + }); + + pi.registerShortcut(Key.ctrlShift("m"), { + description: "Switch model (provider → model → thinking level)", + handler: async (ctx) => { + await runModelSwitcher(pi, ctx); + }, + }); +} diff --git a/extensions/pi-web-access/CHANGELOG.md b/extensions/pi-web-access/CHANGELOG.md new file mode 100644 index 0000000..c41bdb3 --- /dev/null +++ b/extensions/pi-web-access/CHANGELOG.md @@ -0,0 +1,262 @@ +# Pi Web Access - Changelog + +All notable changes to this project will be documented in this file. + +## [0.10.2] - 2026-02-18 + +### Added +- **Interactive search curation.** Press Ctrl+Shift+S during or after a multi-query search to open a browser-based review UI. Results stream in live via SSE. Pick which queries to keep, add new searches on the fly, switch providers — then submit to send only the curated results to the agent. +- **Auto-condense pipeline.** When the countdown expires without manual curation, a single LLM call (Claude Haiku by default) condenses all search results into a deduplicated briefing organized by topic. Preprocessing enriches the prompt with URL overlap, answer similarity, and source quality analysis. Configure via `"autoFilter"` in `~/.pi/web-search.json`. Full uncondensed results stored and retrievable via `get_search_content`. +- **Configurable keyboard shortcuts.** Both shortcuts (curate: Ctrl+Shift+S, activity monitor: Ctrl+Shift+W) can be remapped via `"shortcuts"` in `~/.pi/web-search.json`. Changes take effect on restart. +- **`/websearch` command** — opens the curator directly from pi without an agent round-trip. Accepts optional comma-separated queries or opens empty. +- **Task-aware condensation.** Optional `context` parameter on `web_search` — a brief description of the user's task. The condenser uses it to focus the briefing on what matters. +- **Provider selection** — global dropdown in the curator UI to switch between Perplexity and Gemini. Persists to `~/.pi/web-search.json`. +- **Live condense status in countdown.** Shows "condensing..." while the LLM is working, then "N searches condensed" once complete. +- Markdown rendering in curator result cards via marked.js. +- Query-level result cards with expandable answers and source lists. Check/uncheck to include or exclude. +- SSE streaming with keepalive, socket health checks, and buffered delivery. +- Idle-based timer (60s default, adjustable). Timeout sends all results as safe default. +- Keyboard shortcuts: Enter (submit), Escape (skip), A (toggle all). +- Dark/light theme via `prefers-color-scheme` with teal accent palette. + +### Changed +- **Curate enabled by default.** Multi-query searches show a 10-second review window; single queries send immediately. Pass `curate: false` to opt out. +- **Curate shortcut opens browser immediately, even mid-search.** Remaining results stream in live via SSE. +- **Tool descriptions encourage multi-query research.** The `queries` param explains how to vary phrasing and scope across 2-4 queries, with good/bad examples. +- **Curated results instruct the LLM.** Tool output prefixed with an instruction telling the LLM to use curated results as-is. +- Expanded view shows full answer text per query with source titles and domains. +- Non-curated `web_search` calls now respect the saved provider preference. +- Config helpers generalized from `loadSavedProvider`/`saveProvider` to `loadConfig`/`saveConfig`. + +### Fixed +- Curated `onSubmit` passed the original full query list instead of the filtered list, inflating `queryCount`. +- Collapsed curated status mixed source URL counts with query counts. + +### New files +- `curator-server.ts` — ephemeral HTTP server with SSE streaming, state machine, heartbeat watchdog, and token auth. +- `curator-page.ts` — HTML/CSS/JS for the curator UI with markdown rendering and overlay transitions. +- `search-filter.ts` — auto-condense pipeline: preprocessing, LLM condensation via pi's model registry, and post-processing (citation verification, source list completion). + +## [0.7.3] - 2026-02-05 + +### Added +- Jina Reader fallback for JS-rendered pages. When Readability returns insufficient content (cookie notices, consent walls, SPA shells), the extraction chain now tries Jina Reader (`r.jina.ai`) before falling back to Gemini. Jina handles JavaScript rendering server-side and returns clean markdown. No API key required. +- JS-render detection heuristic (`isLikelyJSRendered`) produces more specific error messages when pages appear to load content dynamically. +- Actionable guidance when all extraction methods fail, listing steps to configure Gemini API or use `web_search` instead. + +### Changed +- HTTP fetch headers now mimic Chrome (realistic `User-Agent`, `Sec-Fetch-*`, `Accept-Language`) instead of the default Node.js user agent. Reduces blocks from bot-detection systems. +- Short Readability output (< 500 chars) is now treated as a content failure, triggering the fallback chain. Previously, a 266-char cookie notice was returned as "successful" content. +- Extraction fallback order is now: HTTP+Readability → RSC → Jina Reader → Gemini URL Context → Gemini Web → error with guidance. + +### Fixed +- `parseTimestamp` now rejects negative values in colon-separated format (`-1:30`, `1:-30`). Previously only the numeric path (`-90`) rejected negatives, while the colon path computed and returned negative seconds. + +## [0.7.2] - 2026-02-03 + +### Added +- `model` parameter on `fetch_content` to override the Gemini model per-request (e.g. `model: "gemini-2.5-flash"`) +- Collapsed TUI results now show a 200-char text preview instead of just the status line +- LICENSE file (MIT) + +### Changed +- Default Gemini model updated from `gemini-2.5-flash` to `gemini-3-flash-preview` across all API, search, URL context, YouTube, and video paths. Gemini Web gracefully falls back to `gemini-2.5-flash` when the model header isn't available. +- README rewritten: added tagline, badges, "Why" section, Quick Start, corrected "How It Works" routing order, fixed inaccurate env var precedence claim, added missing `/v/` YouTube format, restored `/search` command docs, collapsible Files table + +### Fixed +- `PERPLEXITY_API_KEY` env var now takes precedence over config file value, matching `GEMINI_API_KEY` behavior and README documentation (was reversed) +- `package.json` now includes `repository`, `homepage`, `bugs`, and `description` fields (repo link was missing from pi packages site) + +## [0.7.0] - 2026-02-03 + +### Added +- **Multi-provider web search**: `web_search` now supports Perplexity, Gemini API (with Google Search grounding), and Gemini Web (cookie auth) as search providers. New `provider` parameter (`auto`, `perplexity`, `gemini`) controls selection. In `auto` mode (default): Perplexity → Gemini API → Gemini Web. Backwards-compatible — existing Perplexity users see no change. +- **Gemini API grounded search**: Structured citations via `groundingMetadata` with source URIs and text-to-source mappings. Google proxy URLs are resolved via HEAD redirects. Configured via `GEMINI_API_KEY` or `geminiApiKey` in config. +- **Gemini Web search**: Zero-config web search for users signed into Google in Chrome. Prompt instructs Gemini to cite sources; URLs extracted from markdown response. +- **Gemini extraction fallback**: When `fetch_content` fails (HTTP 403/429, Readability fails, network errors), automatically retries via Gemini URL Context API then Gemini Web extraction. Each has an independent 60s timeout. Handles SPAs, JS-heavy pages, and anti-bot protections. +- **Local video file analysis**: `fetch_content` accepts file paths to video files (MP4, MOV, WebM, AVI, etc.). Detected by path prefix (`/`, `./`, `../`, `file://`), validated by extension and 50MB limit. Two-tier fallback: Gemini API (resumable upload via Files API with proper MIME types, poll-until-active and cleanup) → Gemini Web (free, cookie auth). +- **Video prompt parameter**: `fetch_content` gains optional `prompt` parameter for asking specific questions about video content. Threads through YouTube and local video extraction. Without prompt, uses default extraction (transcript + visual descriptions). +- **Video thumbnails**: YouTube results include the video thumbnail (fetched from `img.youtube.com`). Local video results include a frame extracted via ffmpeg (at ~1 second). Returned as image content parts alongside text — the agent sees the thumbnail as vision context. +- **Configurable frame extraction**: `frames` parameter (1-12) on `fetch_content` for pulling visual frames from YouTube or local video. Works in five modes: frames alone (sample across entire video), single timestamp (one frame), single+frames (N frames at 5s intervals), range (default 6 frames), range+frames (N frames across the range). Endpoint-inclusive distribution with 5-second minimum spacing. +- **Video duration in responses**: Frame extraction results include the video duration for context. +- `searchProvider` config option in `~/.pi/web-search.json` for global provider default +- `video` config section: `enabled`, `preferredModel`, `maxSizeMB` + +### Changed +- `PerplexityResponse` renamed to `SearchResponse` (shared interface for all search providers) +- Extracted HTTP pipeline from `extractContent` into `extractViaHttp` for cleaner Gemini fallback orchestration +- `getApiKey()`, `API_BASE`, `DEFAULT_MODEL` exported from `gemini-api.ts` for use by search and URL Context modules +- `isPerplexityAvailable()` added to `perplexity.ts` as non-throwing API key check +- Content-type routing in `extract.ts`: only `text/html` and `application/xhtml+xml` go through Readability; all other text types (`text/markdown`, `application/json`, `text/csv`, etc.) returned directly. Fixes the OpenAI cookbook `.md` URL that returned "Untitled (30 chars)". +- Title extraction for non-HTML content: `extractTextTitle()` pulls from markdown `#`/`##` headings, falls back to URL filename +- Combined `yt-dlp --print duration -g` call fetches stream URL and duration in a single invocation, reused across all frame extraction paths via `streamInfo` passthrough +- Shared helpers in `utils.ts` (`formatSeconds`, error mapping) eliminate circular imports and duplication across youtube-extract.ts and video-extract.ts + +### Fixed +- `fetch_content` TUI rendered `undefined/undefined URLs` during progress updates (renderResult didn't handle `isPartial`, now shows a progress bar like `web_search` does) +- RSC extractor produced malformed markdown for `
` blocks (backticks inside fenced code blocks) -- extremely common on Next.js documentation pages
+- Multi-URL fetch failures rendered in green "success" color even when 0 URLs succeeded (now red)
+- `web_search` queries parameter described as "parallel" in schema but execution is sequential (changed to "batch"; `urls` correctly remains "parallel")
+- Proper error propagation for frame extraction: missing binaries (yt-dlp, ffmpeg, ffprobe), private/age-restricted/region-blocked videos, expired stream URLs (403), timestamp-exceeds-duration, and timeouts all produce specific user-facing messages instead of silent nulls
+- `isTimeoutError` now detects `execFileSync` timeouts via the `killed` flag (SIGTERM from timeout was previously unrecognized)
+- Float video durations (e.g. 15913.7s from yt-dlp) no longer produce out-of-range timestamps — durations are floored before computing frame positions
+- `parseTimestamp` consistently floors results across both bare-number ("90.5" → 90) and colon ("1:30.5" → 90) paths — previously the colon path returned floats
+- YouTube thumbnail assignment no longer sets `null` on the optional `thumbnail` field when fetch fails (was a type mismatch; now only assigned on success)
+
+### New files
+- `gemini-search.ts` -- search routing + Gemini Web/API search providers with grounding
+- `gemini-url-context.ts` -- URL Context API extraction + Gemini Web extraction fallback
+- `video-extract.ts` -- local video file detection, Gemini Web/API analysis with Files API upload
+- `utils.ts` -- shared formatting and error helpers for frame extraction
+
+## [0.6.0] - 2026-02-02
+
+### Added
+- YouTube video understanding in `fetch_content` via three-tier fallback chain:
+  - **Gemini Web** (primary): reads Chrome session cookies from macOS Keychain + SQLite, authenticates to gemini.google.com, sends YouTube URL via StreamGenerate endpoint. Full visual + audio understanding with timestamps. Zero config needed if signed into Google in Chrome.
+  - **Gemini API** (secondary): direct REST calls with `GEMINI_API_KEY`. YouTube URLs passed as `file_data.file_uri`. Configure via `GEMINI_API_KEY` env var or `geminiApiKey` in `~/.pi/web-search.json`.
+  - **Perplexity** (fallback): uses existing `searchWithPerplexity` for a topic summary when neither Gemini path is available. Output labeled as "Summary (via Perplexity)" so the agent knows it's not a full transcript.
+- YouTube URL detection for all common formats: `/watch?v=`, `youtu.be/`, `/shorts/`, `/live/`, `/embed/`, `/v/`, `m.youtube.com`
+- Configurable via `~/.pi/web-search.json` under `youtube` key (`enabled`, `preferredModel`)
+- Actionable error messages when extraction fails (directs user to sign into Chrome or set API key)
+- YouTube URLs no longer fall through to HTTP/Readability (which returns garbage); returns error instead
+
+### New files
+- `chrome-cookies.ts` -- macOS Chrome cookie extraction using Node builtins (`node:crypto`, `node:sqlite`, `child_process`)
+- `gemini-web.ts` -- Gemini Web client ported from surf's gemini-client.cjs (cookie auth, StreamGenerate, model fallback)
+- `gemini-api.ts` -- Gemini REST API client (generateContent, file upload/processing/cleanup for Phase 2)
+- `youtube-extract.ts` -- YouTube extraction orchestrator with three-tier fallback and activity logging
+
+## [0.5.1] - 2026-02-02
+
+### Added
+- Bundled `librarian` skill -- structured research workflow for open-source libraries with GitHub permalinks, combining fetch_content (cloning), web_search (recent info), and git operations (blame, log, show)
+
+### Fixed
+- Session fork event handler was registered as `session_branch` (non-existent event) instead of `session_fork`, meaning forks never triggered cleanup (abort pending fetches, clear clone cache, restore session data)
+- API fallback title for tree URLs with a path (e.g. `/tree/main/src`) now includes the path (`owner/repo - src`), consistent with clone-based results
+- Removed unnecessary export on `getDefaultBranch` (only used internally by `fetchViaApi`)
+
+## [0.5.0] - 2026-02-01
+
+### Added
+- GitHub repository clone extraction for `fetch_content` -- detects GitHub code URLs, clones repos to `/tmp/pi-github-repos/`, and returns actual file contents plus local path for further exploration with `read` and `bash`
+- Lightweight API fallback for oversized repos (>350MB) and commit SHA URLs via `gh api`
+- Clone cache with concurrent request deduplication (second request awaits first's clone)
+- `forceClone` parameter on `fetch_content` to override the size threshold
+- Configurable via `~/.pi/web-search.json` under `githubClone` key (enabled, maxRepoSizeMB, cloneTimeoutSeconds, clonePath)
+- Falls back to `git clone` when `gh` CLI is unavailable (public repos only)
+- README: GitHub clone documentation with config, flow diagram, and limitations
+
+### Changed
+- Refactored `extractContent`/`fetchAllContent` signatures from positional `timeoutMs` to `ExtractOptions` object
+- Blob/tree fetch titles now include file path (e.g. `owner/repo - src/index.ts`) for better disambiguation in multi-URL results and TUI
+
+### Fixed
+- README: Activity monitor keybinding corrected from `Ctrl+Shift+O` to `Ctrl+Shift+W`
+
+## [0.4.5] - 2026-02-01
+
+### Changed
+- Added package keywords for npm discoverability
+
+## [0.4.4] - 2026-02-01
+
+### Fixed
+- Adapt execute signatures to pi v0.51.0: reorder signal, onUpdate, ctx parameters across all three tools
+
+## [0.4.3] - 2026-01-27
+
+### Fixed
+- Google API compatibility: Use `StringEnum` for `recencyFilter` to avoid unsupported `anyOf`/`const` JSON Schema patterns
+
+## [0.4.2] - 2026-01-27
+
+### Fixed
+
+- Single-URL fetches now store content for retrieval via `get_search_content` (previously only multi-URL)
+- Corrected `get_search_content` usage syntax in fetch_content help messages
+
+### Changed
+
+- Increased inline content limit from 10K to 30K chars (larger content truncated but fully retrievable)
+
+### Added
+
+- Banner image for README
+
+## [0.4.1] - 2026-01-26
+
+### Changed
+- Added `pi` manifest to package.json for pi v0.50.0 package system compliance
+- Added `pi-package` keyword for npm discoverability
+
+## [0.4.0] - 2026-01-19
+
+### Added
+
+- PDF extraction via `unpdf` - fetches PDFs from URLs and saves as markdown to `~/Downloads/`
+  - Extracts text, metadata (title, author), page count
+  - Supports PDFs up to 20MB (vs 5MB for HTML)
+  - Handles arxiv URLs with smart title fallback
+
+### Fixed
+
+- Plain text URL detection now uses hostname check instead of substring match
+
+## [0.3.0] - 2026-01-19
+
+### Added
+
+- RSC (React Server Components) content extraction for Next.js App Router pages
+  - Parses flight data from `` tags
+  - Reconstructs markdown with headings, tables, code blocks, links
+  - Handles chunk references and nested components
+  - Falls back to RSC extraction when Readability fails
+- Content-type validation rejects binary files (images, PDFs, audio, video, zip)
+- 5MB response size limit (checked via Content-Length header) to prevent memory issues
+
+### Fixed
+
+- `fetch_content` now handles plain text URLs (raw.githubusercontent.com, gist.githubusercontent.com, any text/plain response) instead of failing with "Could not extract readable content"
+
+## [0.2.0] - 2026-01-11
+
+### Added
+
+- Activity monitor widget (`Ctrl+Shift+O`) showing live request/response activity
+  - Displays last 10 API calls and URL fetches with status codes and timing
+  - Shows rate limit usage and reset countdown
+  - Live updates as requests complete
+  - Auto-clears on session switch
+
+### Changed
+
+- Refactored activity tracking into dedicated `activity.ts` module
+
+## [0.1.0] - 2026-01-06
+
+Initial release. Designed for pi v0.37.3.
+
+### Added
+
+- `web_search` tool - Search via Perplexity AI with synthesized answers and citations
+  - Single or batch queries (parallel execution)
+  - Recency filter (day/week/month/year)
+  - Domain filter (include or exclude)
+  - Optional async content fetching with agent notification
+- `fetch_content` tool - Fetch and extract readable content from URLs
+  - Single URL returns content directly
+  - Multiple URLs store for retrieval via `get_search_content`
+  - Concurrent fetching (3 max) with 30s timeout
+- `get_search_content` tool - Retrieve stored search results or fetched content
+  - Access by response ID, URL, query, or index
+- `/search` command - Interactive browser for stored results
+- TUI rendering with progress bars, URL lists, and expandable previews
+- Session-aware storage with 1-hour TTL
+- Rate limiting (10 req/min for Perplexity API)
+- Config file support (`~/.pi/web-search.json`)
+- Content extraction via Readability + Turndown (max 10k chars)
+- Proper session isolation - pending fetches abort on session switch
+- URL validation before fetch attempts
+- Defensive JSON parsing for API responses
diff --git a/extensions/pi-web-access/LICENSE b/extensions/pi-web-access/LICENSE
new file mode 100644
index 0000000..20aff8a
--- /dev/null
+++ b/extensions/pi-web-access/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Nico Bailon
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/extensions/pi-web-access/README.md b/extensions/pi-web-access/README.md
new file mode 100644
index 0000000..a05c61b
--- /dev/null
+++ b/extensions/pi-web-access/README.md
@@ -0,0 +1,334 @@
+

+ pi-web-access +

+ +# Pi Web Access + +**Web search, content extraction, and video understanding for Pi agent. Zero config with Chrome, or bring your own API keys.** + +[![npm version](https://img.shields.io/npm/v/pi-web-access?style=for-the-badge)](https://www.npmjs.com/package/pi-web-access) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) +[![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux%20%7C%20Windows*-blue?style=for-the-badge)]() + +https://github.com/user-attachments/assets/cac6a17a-1eeb-4dde-9818-cdf85d8ea98f + +## Why Pi Web Access + +**Zero Config** — Signed into Google in Chrome? That's it. The extension reads your Chrome session cookies to access Gemini directly. No API keys, no setup, no subscriptions. + +**Video Understanding** — Point it at a YouTube video or local screen recording and ask questions about what's on screen. Full transcripts, visual descriptions, and frame extraction at exact timestamps. + +**Smart Fallbacks** — Every capability has a fallback chain. Search tries Perplexity, then Gemini API, then Gemini Web. YouTube tries Gemini Web, then API, then Perplexity. Blocked pages retry through Jina Reader and Gemini extraction. Something always works. + +**GitHub Cloning** — GitHub URLs are cloned locally instead of scraped. The agent gets real file contents and a local path to explore, not rendered HTML. + +## Install + +```bash +pi install npm:pi-web-access +``` + +If you're not signed into Chrome, or prefer a different provider, add API keys to `~/.pi/web-search.json`: + +```json +{ + "perplexityApiKey": "pplx-...", + "geminiApiKey": "AIza..." +} +``` + +You can configure one or both. In `auto` mode (default), `web_search` tries Perplexity first, then Gemini API, then Gemini Web. + +Optional dependencies for video frame extraction: + +```bash +brew install ffmpeg # frame extraction, video thumbnails, local video duration +brew install yt-dlp # YouTube stream URLs for frame extraction +``` + +Without these, video content analysis (transcripts, visual descriptions via Gemini) still works. The binaries are only needed for extracting individual frames as images. + +Requires Pi v0.37.3+. + +## Quick Start + +```typescript +// Search the web +web_search({ query: "TypeScript best practices 2025" }) + +// Fetch a page +fetch_content({ url: "https://docs.example.com/guide" }) + +// Clone a GitHub repo +fetch_content({ url: "https://github.com/owner/repo" }) + +// Understand a YouTube video +fetch_content({ url: "https://youtube.com/watch?v=abc", prompt: "What libraries are shown?" }) + +// Analyze a screen recording +fetch_content({ url: "/path/to/recording.mp4", prompt: "What error appears on screen?" }) +``` + +## Tools + +### web_search + +Search the web via Perplexity AI or Gemini. Returns a synthesized answer with source citations. + +```typescript +web_search({ query: "rust async programming" }) +web_search({ queries: ["query 1", "query 2"] }) +web_search({ query: "latest news", numResults: 10, recencyFilter: "week" }) +web_search({ query: "...", domainFilter: ["github.com"] }) +web_search({ query: "...", provider: "gemini" }) +web_search({ query: "...", includeContent: true }) +web_search({ queries: ["query 1", "query 2"], curate: true }) +``` + +| Parameter | Description | +|-----------|-------------| +| `query` / `queries` | Single query or batch of queries | +| `numResults` | Results per query (default: 5, max: 20) | +| `recencyFilter` | `day`, `week`, `month`, or `year` | +| `domainFilter` | Limit to domains (prefix with `-` to exclude) | +| `provider` | `auto` (default), `perplexity`, or `gemini` | +| `includeContent` | Fetch full page content from sources in background | +| `curate` | Hold results for browser review (default: true for multi-query). Press Ctrl+Shift+S to open browser UI, or wait for countdown to auto-condense and send. Set to false to skip both curation and condensation. | + +### fetch_content + +Fetch URL(s) and extract readable content as markdown. Automatically detects and handles GitHub repos, YouTube videos, PDFs, local video files, and regular web pages. + +```typescript +fetch_content({ url: "https://example.com/article" }) +fetch_content({ urls: ["url1", "url2", "url3"] }) +fetch_content({ url: "https://github.com/owner/repo" }) +fetch_content({ url: "https://youtube.com/watch?v=abc", prompt: "What libraries are shown?" }) +fetch_content({ url: "/path/to/recording.mp4", prompt: "What error appears on screen?" }) +fetch_content({ url: "https://youtube.com/watch?v=abc", timestamp: "23:41-25:00", frames: 4 }) +``` + +| Parameter | Description | +|-----------|-------------| +| `url` / `urls` | Single URL/path or multiple URLs | +| `prompt` | Question to ask about a YouTube video or local video file | +| `timestamp` | Extract frame(s) — single (`"23:41"`), range (`"23:41-25:00"`), or seconds (`"85"`) | +| `frames` | Number of frames to extract (max 12) | +| `forceClone` | Clone GitHub repos that exceed the 350MB size threshold | + +### get_search_content + +Retrieve stored content from previous searches or fetches. Content over 30,000 chars is truncated in tool responses but stored in full for retrieval here. + +```typescript +get_search_content({ responseId: "abc123", urlIndex: 0 }) +get_search_content({ responseId: "abc123", url: "https://..." }) +get_search_content({ responseId: "abc123", query: "original query" }) +``` + +## Capabilities + +### GitHub repos + +GitHub URLs are cloned locally instead of scraped. The agent gets real file contents and a local path to explore with `read` and `bash`. Root URLs return the repo tree + README, `/tree/` paths return directory listings, `/blob/` paths return file contents. + +Repos over 350MB get a lightweight API-based view instead of a full clone (override with `forceClone: true`). Commit SHA URLs are handled via the API. Clones are cached for the session and wiped on session change. Private repos require the `gh` CLI. + +### YouTube videos + +YouTube URLs are processed via Gemini for full video understanding — visual descriptions, transcripts with timestamps, and chapter markers. Pass a `prompt` to ask specific questions about the video. Results include the video thumbnail so the agent gets visual context alongside the transcript. + +Fallback: Gemini Web → Gemini API → Perplexity (text summary only). Handles all URL formats: `/watch?v=`, `youtu.be/`, `/shorts/`, `/live/`, `/embed/`, `/v/`. + +### Local video files + +Pass a file path (`/`, `./`, `../`, or `file://` prefix) to analyze video content via Gemini. Supports MP4, MOV, WebM, AVI, and other common formats up to 50MB. Pass a `prompt` to ask about specific content. If ffmpeg is installed, a thumbnail frame is included alongside the analysis. + +Fallback: Gemini API (Files API upload) → Gemini Web. + +### Video frame extraction + +Use `timestamp` and/or `frames` on any YouTube URL or local video file to extract visual frames as images. + +```typescript +fetch_content({ url: "...", timestamp: "23:41" }) // single frame +fetch_content({ url: "...", timestamp: "23:41-25:00" }) // range, 6 frames +fetch_content({ url: "...", timestamp: "23:41-25:00", frames: 3 }) // range, custom count +fetch_content({ url: "...", timestamp: "23:41", frames: 5 }) // 5 frames at 5s intervals +fetch_content({ url: "...", frames: 6 }) // sample whole video +``` + +Requires `ffmpeg` (and `yt-dlp` for YouTube). Timestamps accept `H:MM:SS`, `MM:SS`, or bare seconds. + +### PDFs + +PDF URLs are extracted as text and saved to `~/Downloads/` as markdown. The agent can then `read` specific sections without loading the full document into context. Text-based extraction only — no OCR. + +### Blocked pages + +When Readability fails or returns only a cookie notice, the extension retries via Jina Reader (handles JS rendering server-side, no API key needed), then Gemini URL Context API, then Gemini Web extraction. Handles SPAs, JS-heavy pages, and anti-bot protections transparently. Also parses Next.js RSC flight data when present. + +## How It Works + +``` +fetch_content(url) + → Video file? Gemini API (Files API) → Gemini Web + → GitHub URL? Clone repo, return file contents + local path + → YouTube URL? Gemini Web → Gemini API → Perplexity + → HTTP fetch → PDF? Extract text, save to ~/Downloads/ + → HTML? Readability → RSC parser → Jina Reader → Gemini fallback + → Text/JSON/Markdown? Return directly +``` + +## Skills + +### librarian + +Bundled research workflow for investigating open-source libraries. Combines GitHub cloning, web search, and git operations (blame, log, show) to produce evidence-backed answers with permalinks. Pi loads it automatically based on your prompt. Also available via `/skill:librarian` with [pi-skill-palette](https://github.com/nicobailon/pi-skill-palette). + +## Commands + +### /websearch + +Open the search curator directly in the browser. Runs searches and lets you review, add, and select results to send back to the agent — no LLM round-trip needed. + +``` +/websearch # empty page, type your own searches +/websearch react hooks, next.js caching # pre-fill with comma-separated queries +``` + +Results get injected into the conversation when you click Send. The agent sees them and can use them immediately. + +### /search + +Browse stored search results interactively. Lists all results from the current session with their response IDs for easy retrieval. + +## Activity Monitor + +Toggle with **Ctrl+Shift+W** to see live request/response activity: + +``` +─── Web Search Activity ──────────────────────────────────── + API "typescript best practices" 200 2.1s ✓ + GET docs.example.com/article 200 0.8s ✓ + GET blog.example.com/post 404 0.3s ✗ +──────────────────────────────────────────────────────────── +``` + +## Configuration + +All config lives in `~/.pi/web-search.json`. Every field is optional. + +```json +{ + "perplexityApiKey": "pplx-...", + "geminiApiKey": "AIza...", + "provider": "perplexity", + "curateWindow": 10, + "autoFilter": true, + "githubClone": { + "enabled": true, + "maxRepoSizeMB": 350, + "cloneTimeoutSeconds": 30, + "clonePath": "/tmp/pi-github-repos" + }, + "youtube": { + "enabled": true, + "preferredModel": "gemini-3-flash-preview" + }, + "video": { + "enabled": true, + "preferredModel": "gemini-3-flash-preview", + "maxSizeMB": 50 + }, + "shortcuts": { + "curate": "ctrl+shift+s", + "activity": "ctrl+shift+w" + } +} +``` + +`GEMINI_API_KEY` and `PERPLEXITY_API_KEY` env vars take precedence over config file values. `provider` sets the default search provider: `"perplexity"` or `"gemini"`. This is also updated automatically when you change the provider in the curator UI. `curateWindow` controls how many seconds multi-query searches wait before auto-sending results (default: 10). During the countdown, press Ctrl+Shift+S to open the browser curator. Set to 0 to always send immediately (Ctrl+Shift+S still works during the search itself). + +### Shortcuts + +Both shortcuts are configurable via `~/.pi/web-search.json`: + +```json +{ + "shortcuts": { + "curate": "ctrl+shift+s", + "activity": "ctrl+shift+w" + } +} +``` + +Values use the same format as pi keybindings (e.g. `ctrl+s`, `ctrl+shift+s`, `alt+r`). Changes take effect on next pi restart. + +### Auto-Condense + +Multi-query searches are automatically condensed into a deduplicated briefing when the countdown expires without manual curation. A single LLM call receives all search results — enriched with preprocessing analysis (URL overlap, answer similarity, source quality tiers) — and produces a concise synthesis organized by topic. Irrelevant or off-topic results are skipped automatically. + +```json +{ + "autoFilter": { + "enabled": true, + "model": "anthropic/claude-haiku-4-5", + "prompt": "You are a research assistant..." + } +} +``` + +| Field | Description | +|-------|-------------| +| `enabled` | `true` to enable, `false` to disable. Omit `autoFilter` entirely to enable with defaults. | +| `model` | LLM model in `provider/model` format. Uses pi's model registry for auth. Default: `anthropic/claude-haiku-4-5`. | +| `prompt` | System prompt for the condenser. Should instruct the model to synthesize, deduplicate, and cite sources. Omit to use the built-in prompt. | + +Shorthand: `"autoFilter": true` or `"autoFilter": false` for enable/disable without customizing model or prompt. + +The model uses pi's model registry, so any configured provider works — including custom gateways, OAuth tokens, and API keys. If the model isn't found in the registry or has no credentials, condensation is silently skipped. + +The `web_search` tool also accepts an optional `context` parameter — a brief description of the user's current task or goal. When provided, the condenser uses it to focus the briefing (e.g., "building a Shopify checkout extension" helps it emphasize relevant findings over general noise). + +Set `"enabled": false` under any feature to disable it. Config changes require a Pi restart. + +Rate limits: Perplexity is capped at 10 requests/minute (client-side). Content fetches run 3 concurrent with a 30s timeout per URL. + +## Limitations + +- Chrome cookie extraction is macOS-only — other platforms fall through to API keys. First-time access may trigger a Keychain dialog. +- YouTube private/age-restricted videos may fail on all extraction paths. +- Gemini can process videos up to ~1 hour; longer videos may be truncated. +- PDFs are text-extracted only (no OCR for scanned documents). +- GitHub branch names with slashes may misresolve file paths; the clone still works and the agent can navigate manually. +- Non-code GitHub URLs (issues, PRs, wiki) fall through to normal web extraction. + +
+Files + +| File | Purpose | +|------|---------| +| `index.ts` | Extension entry, tool definitions, commands, widget | +| `curator-page.ts` | HTML/CSS/JS generation for the curator UI with markdown rendering | +| `curator-server.ts` | Ephemeral HTTP server with SSE streaming and state machine | +| `search-filter.ts` | Auto-condense pipeline — preprocessing, LLM condensation, and post-processing for multi-query results | +| `extract.ts` | URL/file path routing, HTTP extraction, fallback orchestration | +| `gemini-search.ts` | Search routing across Perplexity, Gemini API, Gemini Web | +| `gemini-url-context.ts` | Gemini URL Context + Web extraction fallbacks | +| `gemini-web.ts` | Gemini Web client (cookie auth, StreamGenerate) | +| `gemini-api.ts` | Gemini REST API client (generateContent) | +| `chrome-cookies.ts` | macOS Chrome cookie extraction (Keychain + SQLite) | +| `youtube-extract.ts` | YouTube detection, three-tier extraction, frame extraction | +| `video-extract.ts` | Local video detection, Files API upload, Gemini analysis | +| `github-extract.ts` | GitHub URL parsing, clone cache, content generation | +| `github-api.ts` | GitHub API fallback for large repos and commit SHAs | +| `perplexity.ts` | Perplexity API client with rate limiting | +| `pdf-extract.ts` | PDF text extraction, saves to markdown | +| `rsc-extract.ts` | RSC flight data parser for Next.js pages | +| `utils.ts` | Shared formatting and error helpers | +| `storage.ts` | Session-aware result storage | +| `activity.ts` | Activity tracking for the observability widget | +| `skills/librarian/` | Bundled skill for library research | + +
diff --git a/extensions/pi-web-access/activity.ts b/extensions/pi-web-access/activity.ts new file mode 100644 index 0000000..9c18304 --- /dev/null +++ b/extensions/pi-web-access/activity.ts @@ -0,0 +1,102 @@ +// Types +export interface ActivityEntry { + id: string; + type: "api" | "fetch"; + startTime: number; + endTime?: number; + + // For API calls + query?: string; + + // For URL fetches + url?: string; + + // Result - status is number (HTTP code) or null (pending/network error) + status: number | null; + error?: string; +} + +export interface RateLimitInfo { + used: number; + max: number; + oldestTimestamp: number | null; + windowMs: number; +} + +export class ActivityMonitor { + private entries: ActivityEntry[] = []; + private readonly maxEntries = 10; + private listeners = new Set<() => void>(); + private rateLimitInfo: RateLimitInfo = { used: 0, max: 10, oldestTimestamp: null, windowMs: 60000 }; + private nextId = 1; + + logStart(partial: Omit): string { + const id = `act-${this.nextId++}`; + const entry: ActivityEntry = { + ...partial, + id, + startTime: Date.now(), + status: null, + }; + this.entries.push(entry); + if (this.entries.length > this.maxEntries) { + this.entries.shift(); + } + this.notify(); + return id; + } + + logComplete(id: string, status: number): void { + const entry = this.entries.find((e) => e.id === id); + if (entry) { + entry.endTime = Date.now(); + entry.status = status; + this.notify(); + } + } + + logError(id: string, error: string): void { + const entry = this.entries.find((e) => e.id === id); + if (entry) { + entry.endTime = Date.now(); + entry.error = error; + this.notify(); + } + } + + getEntries(): readonly ActivityEntry[] { + return this.entries; + } + + getRateLimitInfo(): RateLimitInfo { + return this.rateLimitInfo; + } + + updateRateLimit(info: RateLimitInfo): void { + this.rateLimitInfo = info; + this.notify(); + } + + onUpdate(callback: () => void): () => void { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + clear(): void { + this.entries = []; + this.rateLimitInfo = { used: 0, max: 10, oldestTimestamp: null, windowMs: 60000 }; + this.notify(); + } + + private notify(): void { + for (const cb of this.listeners) { + try { + cb(); + } catch { + /* ignore */ + } + } + } +} + +export const activityMonitor = new ActivityMonitor(); diff --git a/extensions/pi-web-access/chrome-cookies.ts b/extensions/pi-web-access/chrome-cookies.ts new file mode 100644 index 0000000..c71b030 --- /dev/null +++ b/extensions/pi-web-access/chrome-cookies.ts @@ -0,0 +1,240 @@ +import { execFile } from "node:child_process"; +import { pbkdf2Sync, createDecipheriv } from "node:crypto"; +import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir, homedir, platform } from "node:os"; +import { join } from "node:path"; + +export type CookieMap = Record; + +const GOOGLE_ORIGINS = [ + "https://gemini.google.com", + "https://accounts.google.com", + "https://www.google.com", +]; + +const ALL_COOKIE_NAMES = new Set([ + "__Secure-1PSID", + "__Secure-1PSIDTS", + "__Secure-1PSIDCC", + "__Secure-1PAPISID", + "NID", + "AEC", + "SOCS", + "__Secure-BUCKET", + "__Secure-ENID", + "SID", + "HSID", + "SSID", + "APISID", + "SAPISID", + "__Secure-3PSID", + "__Secure-3PSIDTS", + "__Secure-3PAPISID", + "SIDCC", +]); + +const CHROME_COOKIES_PATH = join( + homedir(), + "Library/Application Support/Google/Chrome/Default/Cookies", +); + +export async function getGoogleCookies(): Promise<{ cookies: CookieMap; warnings: string[] } | null> { + if (platform() !== "darwin") return null; + if (!existsSync(CHROME_COOKIES_PATH)) return null; + + const warnings: string[] = []; + + const password = await readKeychainPassword(); + if (!password) { + warnings.push("Could not read Chrome Safe Storage password from Keychain"); + return { cookies: {}, warnings }; + } + + const key = pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1"); + const tempDir = mkdtempSync(join(tmpdir(), "pi-chrome-cookies-")); + + try { + const tempDb = join(tempDir, "Cookies"); + copyFileSync(CHROME_COOKIES_PATH, tempDb); + copySidecar(CHROME_COOKIES_PATH, tempDb, "-wal"); + copySidecar(CHROME_COOKIES_PATH, tempDb, "-shm"); + + const metaVersion = await readMetaVersion(tempDb); + const stripHash = metaVersion >= 24; + + const hosts = GOOGLE_ORIGINS.map((o) => new URL(o).hostname); + const rows = await queryCookieRows(tempDb, hosts); + if (!rows) { + warnings.push("Failed to query Chrome cookie database"); + return { cookies: {}, warnings }; + } + + const cookies: CookieMap = {}; + for (const row of rows) { + const name = row.name as string; + if (!ALL_COOKIE_NAMES.has(name)) continue; + if (cookies[name]) continue; + + let value = typeof row.value === "string" && row.value.length > 0 ? row.value : null; + if (!value) { + const encrypted = row.encrypted_value; + if (encrypted instanceof Uint8Array) { + value = decryptCookieValue(encrypted, key, stripHash); + } + } + if (value) cookies[name] = value; + } + + return { cookies, warnings }; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function decryptCookieValue(encrypted: Uint8Array, key: Buffer, stripHash: boolean): string | null { + const buf = Buffer.from(encrypted); + if (buf.length < 3) return null; + + const prefix = buf.subarray(0, 3).toString("utf8"); + if (!/^v\d\d$/.test(prefix)) return null; + + const ciphertext = buf.subarray(3); + if (!ciphertext.length) return ""; + + try { + const iv = Buffer.alloc(16, 0x20); + const decipher = createDecipheriv("aes-128-cbc", key, iv); + decipher.setAutoPadding(false); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + const unpadded = removePkcs7Padding(plaintext); + const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded; + const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + let i = 0; + while (i < decoded.length && decoded.charCodeAt(i) < 0x20) i++; + return decoded.slice(i); + } catch { + return null; + } +} + +function removePkcs7Padding(buf: Buffer): Buffer { + if (!buf.length) return buf; + const padding = buf[buf.length - 1]; + if (!padding || padding > 16) return buf; + return buf.subarray(0, buf.length - padding); +} + +function readKeychainPassword(): Promise { + return new Promise((resolve) => { + execFile( + "security", + ["find-generic-password", "-w", "-a", "Chrome", "-s", "Chrome Safe Storage"], + { timeout: 5000 }, + (err, stdout) => { + if (err) { resolve(null); return; } + resolve(stdout.trim() || null); + }, + ); + }); +} + +let sqliteModule: typeof import("node:sqlite") | null = null; + +async function importSqlite(): Promise { + if (sqliteModule) return sqliteModule; + const orig = process.emitWarning.bind(process); + process.emitWarning = ((warning: string | Error, ...args: unknown[]) => { + const msg = typeof warning === "string" ? warning : warning?.message ?? ""; + if (msg.includes("SQLite is an experimental feature")) return; + return (orig as Function)(warning, ...args); + }) as typeof process.emitWarning; + try { + sqliteModule = await import("node:sqlite"); + return sqliteModule; + } catch { + return null; + } finally { + process.emitWarning = orig; + } +} + +function supportsReadBigInts(): boolean { + const [major, minor] = process.versions.node.split(".").map(Number); + if (major > 24) return true; + if (major < 24) return false; + return minor >= 4; +} + +async function readMetaVersion(dbPath: string): Promise { + const sqlite = await importSqlite(); + if (!sqlite) return 0; + const opts: Record = { readOnly: true }; + if (supportsReadBigInts()) opts.readBigInts = true; + const db = new sqlite.DatabaseSync(dbPath, opts); + try { + const rows = db.prepare("SELECT value FROM meta WHERE key = 'version'").all() as Array>; + const val = rows[0]?.value; + if (typeof val === "number") return Math.floor(val); + if (typeof val === "bigint") return Number(val); + if (typeof val === "string") return parseInt(val, 10) || 0; + return 0; + } catch { + return 0; + } finally { + db.close(); + } +} + +async function queryCookieRows( + dbPath: string, + hosts: string[], +): Promise> | null> { + const sqlite = await importSqlite(); + if (!sqlite) return null; + + const clauses: string[] = []; + for (const host of hosts) { + for (const candidate of expandHosts(host)) { + const esc = candidate.replaceAll("'", "''"); + clauses.push(`host_key = '${esc}'`); + clauses.push(`host_key = '.${esc}'`); + clauses.push(`host_key LIKE '%.${esc}'`); + } + } + const where = clauses.join(" OR "); + + const opts: Record = { readOnly: true }; + if (supportsReadBigInts()) opts.readBigInts = true; + const db = new sqlite.DatabaseSync(dbPath, opts); + try { + return db + .prepare( + `SELECT name, value, host_key, encrypted_value FROM cookies WHERE (${where}) ORDER BY expires_utc DESC`, + ) + .all() as Array>; + } catch { + return null; + } finally { + db.close(); + } +} + +function expandHosts(host: string): string[] { + const parts = host.split(".").filter(Boolean); + if (parts.length <= 1) return [host]; + const candidates = new Set(); + candidates.add(host); + for (let i = 1; i <= parts.length - 2; i++) { + const c = parts.slice(i).join("."); + if (c) candidates.add(c); + } + return Array.from(candidates); +} + +function copySidecar(srcDb: string, targetDb: string, suffix: string): void { + const sidecar = `${srcDb}${suffix}`; + if (!existsSync(sidecar)) return; + try { + copyFileSync(sidecar, `${targetDb}${suffix}`); + } catch {} +} diff --git a/extensions/pi-web-access/curator-page.ts b/extensions/pi-web-access/curator-page.ts new file mode 100644 index 0000000..5441080 --- /dev/null +++ b/extensions/pi-web-access/curator-page.ts @@ -0,0 +1,1209 @@ +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function safeInlineJSON(data: unknown): string { + return JSON.stringify(data) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026"); +} + +function buildProviderOptions( + available: { perplexity: boolean; gemini: boolean }, + selected: string, +): string { + const options = [ + { value: "perplexity", label: "Perplexity", disabled: !available.perplexity }, + { value: "gemini", label: "Gemini", disabled: !available.gemini }, + ]; + + return options + .map(o => ``) + .join(""); +} + +export function generateCuratorPage( + queries: string[], + sessionToken: string, + timeout: number, + availableProviders: { perplexity: boolean; gemini: boolean }, + defaultProvider: string, +): string { + const providerOptionsHtml = buildProviderOptions(availableProviders, defaultProvider); + const inlineData = safeInlineJSON({ queries, sessionToken, timeout }); + + return ` + + + + +Curate Search Results + + + + + +`; +} + +const CSS = ` +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} + +:root { + --bg: #18181e; + --bg-card: #1e1e24; + --bg-elevated: #252530; + --bg-hover: #2b2b37; + --fg: #e0e0e0; + --fg-muted: #909098; + --fg-dim: #606068; + --accent: #8abeb7; + --accent-hover: #9dcec7; + --accent-muted: rgba(138, 190, 183, 0.15); + --accent-subtle: rgba(138, 190, 183, 0.08); + --border: #2a2a34; + --border-muted: #353540; + --border-checked: #8abeb7; + --check-bg: #8abeb7; + --btn-primary: #8abeb7; + --btn-primary-hover: #9dcec7; + --btn-primary-fg: #18181e; + --btn-secondary: #252530; + --btn-secondary-hover: #2b2b37; + --timer-bg: #252530; + --timer-fg: #909098; + --timer-warn-bg: rgba(240, 198, 116, 0.15); + --timer-warn-fg: #f0c674; + --timer-urgent-bg: rgba(204, 102, 102, 0.15); + --timer-urgent-fg: #cc6666; + --overlay-bg: rgba(24, 24, 30, 0.92); + --success: #b5bd68; + --warning: #f0c674; + --font: 'Outfit', system-ui, -apple-system, sans-serif; + --font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif; + --font-mono: 'SF Mono', Consolas, monospace; + --radius: 10px; + --radius-sm: 6px; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f5f5f7; + --bg-card: #ffffff; + --bg-elevated: #eeeef0; + --bg-hover: #e4e4e8; + --fg: #1a1a1e; + --fg-muted: #6c6c74; + --fg-dim: #9a9aa2; + --accent: #5f8787; + --accent-hover: #4a7272; + --accent-muted: rgba(95, 135, 135, 0.12); + --accent-subtle: rgba(95, 135, 135, 0.06); + --border: #dcdce0; + --border-muted: #c8c8d0; + --border-checked: #5f8787; + --check-bg: #5f8787; + --btn-primary: #5f8787; + --btn-primary-hover: #4a7272; + --btn-primary-fg: #ffffff; + --btn-secondary: #e4e4e8; + --btn-secondary-hover: #d4d4d8; + --timer-bg: #e4e4e8; + --timer-fg: #6c6c74; + --timer-warn-bg: rgba(217, 119, 6, 0.10); + --timer-warn-fg: #92400e; + --timer-urgent-bg: rgba(175, 95, 95, 0.10); + --timer-urgent-fg: #991b1b; + --overlay-bg: rgba(255, 255, 255, 0.92); + --success: #4d7c0f; + --warning: #b45309; + } +} + +body { + font-family: var(--font); + background: var(--bg); + background-image: radial-gradient(ellipse at 50% 0%, var(--accent-muted) 0%, transparent 60%); + color: var(--fg); + line-height: 1.5; + min-height: 100dvh; + padding-bottom: 72px; +} + +.timer-badge { + position: fixed; + top: 20px; + right: 24px; + z-index: 50; + font-family: var(--font); + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + padding: 5px 14px; + border-radius: 999px; + background: var(--bg-elevated); + color: var(--timer-fg); + border: 1px solid var(--border); + transition: background 0.3s, color 0.3s, border-color 0.3s, opacity 0.3s; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + cursor: pointer; + user-select: none; + opacity: 0.5; +} +.timer-badge:hover { opacity: 1; } +.timer-badge.active { opacity: 1; } +.timer-badge.warn { + opacity: 1; + background: var(--timer-warn-bg); + color: var(--timer-warn-fg); + border-color: color-mix(in srgb, var(--timer-warn-fg) 30%, transparent); +} +.timer-badge.urgent { + opacity: 1; + background: var(--timer-urgent-bg); + color: var(--timer-urgent-fg); + border-color: color-mix(in srgb, var(--timer-urgent-fg) 30%, transparent); +} +.timer-adjust { + position: fixed; + top: 20px; + right: 24px; + z-index: 51; + display: none; + align-items: center; + gap: 6px; + padding: 4px 6px 4px 12px; + background: var(--bg-elevated); + border: 1px solid var(--accent); + border-radius: 999px; + box-shadow: 0 2px 12px rgba(0,0,0,0.3); +} +.timer-adjust.visible { display: flex; } +.timer-adjust input { + width: 48px; + background: transparent; + border: none; + outline: none; + color: var(--fg); + font-family: var(--font); + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: center; +} +.timer-adjust-label { font-size: 11px; color: var(--fg-dim); } +.timer-adjust-btn { + font-family: var(--font); + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; + border: none; + background: var(--accent); + color: var(--btn-primary-fg); + cursor: pointer; +} +.timer-adjust-btn:hover { background: var(--accent-hover); } + +main { + max-width: 640px; + margin: 0 auto; + padding: 56px 24px 16px; +} + +.hero { margin-bottom: 28px; } +.hero-kicker { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent); + margin-bottom: 8px; +} +.hero-title { + font-family: var(--font-display); + font-size: 40px; + font-weight: 400; + font-style: italic; + letter-spacing: -0.01em; + line-height: 1.1; + color: var(--fg); + margin-bottom: 10px; + text-wrap: balance; +} +.hero-desc { + font-size: 14px; + color: var(--fg-muted); + line-height: 1.5; + margin-bottom: 12px; + max-width: 480px; +} +.hero-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--fg-dim); +} +.hero-meta-sep { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--fg-dim); + flex-shrink: 0; +} +#hero-status:empty + .hero-meta-sep { display: none; } +.hero-meta select { + font-family: var(--font); + font-size: 13px; + padding: 3px 8px; + background: transparent; + border: 1px solid transparent; + color: var(--fg-muted); + border-radius: var(--radius-sm); + font-weight: 500; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} +.hero-meta select:hover { + border-color: var(--border-muted); + color: var(--fg); +} +.hero-meta select:focus { + outline: none; + border-color: var(--accent); + color: var(--fg); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); +} + +#result-cards { display: flex; flex-direction: column; gap: 8px; } + +.result-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: border-color 0.12s; + box-shadow: 0 1px 2px rgba(0,0,0,0.06); +} +.result-card.checked { border-color: var(--border-checked); } +.result-card.searching { + opacity: 0.7; + border-style: dashed; +} +.result-card.error { border-color: var(--timer-urgent-fg); } + +.result-card-header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + cursor: pointer; + user-select: none; + transition: background 0.12s; +} +.result-card-header:hover { background: var(--bg-hover); } + +.result-card-header input[type="checkbox"] { + appearance: none; + width: 16px; + height: 16px; + min-width: 16px; + border: 1.5px solid var(--border-muted); + border-radius: 4px; + margin-top: 2px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; + display: grid; + place-content: center; +} +.result-card-header input[type="checkbox"]:checked { + background: var(--check-bg); + border-color: var(--check-bg); +} +.result-card-header input[type="checkbox"]:checked::after { + content: ""; + width: 9px; + height: 6px; + border-left: 2px solid var(--btn-primary-fg); + border-bottom: 2px solid var(--btn-primary-fg); + transform: rotate(-45deg); + margin-top: -1px; +} + +.result-card-info { flex: 1; min-width: 0; } + +.result-card-query { + font-size: 14px; + font-weight: 600; + color: var(--fg); + margin-bottom: 2px; +} +.result-card-meta { + font-size: 12px; + color: var(--fg-dim); +} +.result-card-preview { + font-size: 12.5px; + color: var(--fg-muted); + margin-top: 6px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.45; +} + +.result-card-expand { + color: var(--fg-dim); + font-size: 11px; + margin-top: 2px; + flex-shrink: 0; + padding-top: 3px; + transition: color 0.12s; +} +.result-card-header:hover .result-card-expand { color: var(--fg-muted); } + +.result-card-body { + display: none; + border-top: 1px solid var(--border); +} +.result-card-body.open { display: block; } + +.result-card-answer { + padding: 14px 16px; + font-size: 13.5px; + color: var(--fg-muted); + line-height: 1.6; + max-height: 400px; + overflow-y: auto; +} +.result-card-answer h1, +.result-card-answer h2, +.result-card-answer h3, +.result-card-answer h4 { + color: var(--fg); + font-family: var(--font); + font-weight: 600; + margin: 16px 0 6px; + line-height: 1.3; +} +.result-card-answer h1 { font-size: 16px; } +.result-card-answer h2 { font-size: 14.5px; } +.result-card-answer h3 { font-size: 13.5px; } +.result-card-answer h4 { font-size: 13px; color: var(--fg-muted); } +.result-card-answer p { margin: 0 0 10px; } +.result-card-answer p:last-child { margin-bottom: 0; } +.result-card-answer strong { color: var(--fg); font-weight: 600; } +.result-card-answer a { color: var(--accent); text-decoration: none; } +.result-card-answer a:hover { text-decoration: underline; } +.result-card-answer ul, .result-card-answer ol { + margin: 6px 0 10px; + padding-left: 20px; +} +.result-card-answer li { margin-bottom: 4px; } +.result-card-answer li::marker { color: var(--fg-dim); } +.result-card-answer code { + font-family: var(--font-mono); + font-size: 12px; + padding: 1px 5px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--fg); +} +.result-card-answer pre { + margin: 8px 0 12px; + padding: 12px 14px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow-x: auto; + line-height: 1.45; +} +.result-card-answer pre code { + padding: 0; + background: none; + border: none; + font-size: 12px; + color: var(--fg-muted); +} +.result-card-answer blockquote { + margin: 8px 0; + padding: 8px 14px; + border-left: 3px solid var(--accent); + color: var(--fg-dim); + background: var(--accent-subtle); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +.result-card-answer table { + width: 100%; + border-collapse: collapse; + margin: 8px 0 12px; + font-size: 12.5px; +} +.result-card-answer th, .result-card-answer td { + padding: 6px 10px; + border: 1px solid var(--border); + text-align: left; +} +.result-card-answer th { + background: var(--bg-elevated); + color: var(--fg); + font-weight: 600; + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.result-card-answer hr { + border: none; + border-top: 1px solid var(--border); + margin: 14px 0; +} + +.result-card-sources { + padding: 10px 16px 14px; + border-top: 1px solid var(--border); +} +.result-card-sources-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-dim); + margin-bottom: 6px; +} +.source-link { + display: block; + padding: 4px 0; + font-size: 12.5px; + color: var(--fg-muted); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: color 0.12s; +} +.source-link:hover { color: var(--accent); } +.source-domain { + color: var(--fg-dim); + margin-left: 6px; +} + +.result-card-error-msg { + padding: 12px 16px; + font-size: 13px; + color: var(--timer-urgent-fg); +} + +.searching-dots::after { + content: ""; + animation: dots 1.5s steps(4, end) infinite; +} +@keyframes dots { + 0% { content: ""; } + 25% { content: "."; } + 50% { content: ".."; } + 75% { content: "..."; } +} + +.add-search { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding: 11px 14px; + border: 1px dashed var(--border); + border-radius: var(--radius); + cursor: text; + transition: border-color 0.15s, background 0.15s; +} +.add-search:hover { + border-color: var(--border-muted); + background: var(--accent-subtle); +} +.add-search:focus-within { + border-color: var(--accent); + border-style: solid; + background: var(--accent-subtle); +} +.add-search-icon { + color: var(--fg-dim); + font-size: 16px; + font-weight: 300; + line-height: 1; + flex-shrink: 0; + transition: color 0.15s; +} +.add-search:focus-within .add-search-icon { color: var(--accent); } +.add-search input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--fg); + font-family: var(--font); + font-size: 13.5px; + font-weight: 500; +} +.add-search input::placeholder { + color: var(--fg-dim); + font-weight: 400; +} +.add-search.loading { + opacity: 0.5; + pointer-events: none; +} + +.action-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: color-mix(in srgb, var(--bg) 90%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-top: 1px solid var(--border); +} +.action-shortcuts { display: flex; align-items: center; gap: 16px; } +.shortcut { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--fg-dim); } +.shortcut kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + background: var(--bg-elevated); + border: 1px solid var(--border-muted); + border-radius: 3px; + color: var(--fg-muted); +} +.action-buttons { display: flex; gap: 8px; } + +.btn { + font-family: var(--font); + font-size: 13px; + font-weight: 500; + padding: 7px 16px; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.12s, opacity 0.12s; +} +.btn:disabled { opacity: 0.35; cursor: default; } +.btn-submit { background: var(--btn-primary); color: var(--btn-primary-fg); } +.btn-submit:hover:not(:disabled) { background: var(--btn-primary-hover); } +.btn-secondary { background: var(--btn-secondary); color: var(--fg-muted); border: 1px solid var(--border); } +.btn-secondary:hover:not(:disabled) { background: var(--btn-secondary-hover); color: var(--fg); } + +.success-overlay { + position: fixed; inset: 0; z-index: 200; + background: var(--overlay-bg); + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; + transition: opacity 200ms; +} +.success-overlay.hidden { display: flex !important; opacity: 0; pointer-events: none; } +.success-icon { + width: 56px; height: 56px; border-radius: 50%; + border: 2px solid var(--success); + display: flex; align-items: center; justify-content: center; + font-size: 18px; font-weight: 700; color: var(--success); +} +.success-overlay p { margin: 0; font-size: 13px; font-weight: 600; color: var(--success); letter-spacing: 0.06em; text-transform: uppercase; } + +.expired-overlay { + position: fixed; inset: 0; + background: var(--overlay-bg); + display: flex; align-items: center; justify-content: center; + opacity: 0; transition: opacity 400ms; pointer-events: none; z-index: 200; +} +.expired-overlay.visible { opacity: 1; pointer-events: auto; } +.expired-overlay.hidden { display: flex !important; opacity: 0; pointer-events: none; } +.expired-content { + text-align: center; max-width: 480px; padding: 48px 56px; + background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; +} +.expired-overlay.visible .expired-content { animation: slide-up 400ms ease-out; } +@keyframes slide-up { from { transform: translateY(20px); } to { transform: translateY(0); } } +.expired-icon { + width: 72px; height: 72px; border-radius: 50%; border: 2px solid var(--warning); + display: flex; align-items: center; justify-content: center; + font-size: 32px; font-weight: bold; color: var(--warning); margin: 0 auto 24px; +} +.expired-content h2 { color: var(--fg); margin: 0 0 16px; font-size: 22px; font-weight: 600; } +.expired-content p { color: var(--fg-muted); margin: 0 0 24px; font-size: 14px; line-height: 1.6; } +.expired-countdown { font-size: 13px; color: var(--fg-dim); font-variant-numeric: tabular-nums; } +.expired-countdown span { color: var(--warning); font-weight: 600; } + +.error-banner { + position: fixed; bottom: 64px; left: 50%; transform: translateX(-50%); z-index: 50; + padding: 10px 20px; background: var(--timer-urgent-bg); color: var(--timer-urgent-fg); + border-radius: var(--radius); font-size: 13px; font-weight: 500; +} + +@media (max-width: 500px) { + main { padding: 32px 16px 16px; } + .hero-title { font-size: 28px; } + .hero-desc { font-size: 13px; } + .action-bar { padding: 10px 14px; } + .action-shortcuts { display: none; } + .result-card-header { padding: 12px 14px; } + .expired-content { padding: 32px 24px; } + .timer-badge { top: 12px; right: 16px; } +} +`; + +const SCRIPT = `(function() { + var DATA = __INLINE_DATA__; + var token = DATA.sessionToken; + var timeoutSec = DATA.timeout; + var queries = DATA.queries; + var submitted = false; + var timerExpired = false; + var searchesDone = false; + var lastInteraction = Date.now(); + var completedCount = 0; + var es = null; + + var timerEl = document.getElementById("timer"); + var timerAdjustEl = document.getElementById("timer-adjust"); + var timerInput = document.getElementById("timer-input"); + var timerSetBtn = document.getElementById("timer-set"); + var heroTitle = document.querySelector(".hero-title"); + var heroDesc = document.querySelector(".hero-desc"); + var resultCardsEl = document.getElementById("result-cards"); + var btnSendAll = document.getElementById("btn-send-all"); + var btnSend = document.getElementById("btn-send"); + var successOverlay = document.getElementById("success-overlay"); + var successText = document.getElementById("success-text"); + var expiredOverlay = document.getElementById("expired-overlay"); + var expiredText = document.getElementById("expired-text"); + var closeCountdown = document.getElementById("close-countdown"); + var errorBanner = document.getElementById("error-banner"); + var addSearchInput = document.getElementById("add-search-input"); + var addSearchEl = document.getElementById("add-search"); + var heroStatus = document.getElementById("hero-status"); + var globalProvider = document.getElementById("global-provider"); + + function escHtml(s) { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + function post(path, body) { + return fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(Object.assign({ token: token }, body)), + }); + } + + function formatTime(sec) { + var m = Math.floor(sec / 60); + var s = sec % 60; + return m + ":" + (s < 10 ? "0" : "") + s; + } + + function populateResultCard(card, data, queryText) { + var sourceCount = data.results ? data.results.length : 0; + var domains = []; + if (data.results) { + for (var i = 0; i < Math.min(data.results.length, 3); i++) { + domains.push(data.results[i].domain); + } + } + var metaText = sourceCount + " source" + (sourceCount !== 1 ? "s" : ""); + if (domains.length > 0) metaText += " \\u00B7 " + domains.join(", "); + if (sourceCount > 3) metaText += ", +" + (sourceCount - 3); + + var preview = ""; + if (data.answer) { + preview = data.answer.substring(0, 200).replace(/\\n+/g, " ").replace(/[#*_\\[\\]]/g, ""); + } + + var bodyHtml = ""; + if (data.answer) { + var rendered = typeof marked !== "undefined" && marked.parse + ? marked.parse(data.answer, { breaks: true }) + : '

' + escHtml(data.answer) + '

'; + bodyHtml += '
' + rendered + '
'; + } + if (data.results && data.results.length > 0) { + bodyHtml += '
Sources
'; + for (var k = 0; k < data.results.length; k++) { + var r = data.results[k]; + var label = r.title && r.title.indexOf("Source ") !== 0 ? r.title : r.url; + bodyHtml += '' + escHtml(label) + '' + escHtml(r.domain) + ''; + } + bodyHtml += '
'; + } + + card.innerHTML = + '
' + + '' + + '
' + + '
' + escHtml(queryText) + '
' + + '
' + escHtml(metaText) + '
' + + (preview ? '
' + escHtml(preview) + '
' : '') + + '
' + + '
\\u25BC
' + + '
' + + '
' + bodyHtml + '
'; + } + + // ── Timer ── + + function resetTimer() { lastInteraction = Date.now(); } + + function updateTimer() { + var idleSec = Math.floor((Date.now() - lastInteraction) / 1000); + var remaining = Math.max(0, timeoutSec - idleSec); + timerEl.textContent = formatTime(remaining); + + timerEl.classList.remove("warn", "urgent", "active"); + if (remaining <= 15) timerEl.classList.add("urgent"); + else if (remaining <= 30) timerEl.classList.add("warn"); + else if (remaining < timeoutSec) timerEl.classList.add("active"); + + if (remaining <= 0 && !submitted && !timerExpired) onTimeout(); + } + + setInterval(updateTimer, 1000); + updateTimer(); + + ["click", "keydown", "input", "change"].forEach(function(evt) { + document.addEventListener(evt, resetTimer, { passive: true }); + }); + document.addEventListener("scroll", resetTimer, { passive: true }); + document.addEventListener("mousemove", resetTimer, { passive: true }); + + // ── Timer adjust ── + + timerEl.addEventListener("click", function(e) { + e.stopPropagation(); + timerInput.value = timeoutSec; + timerAdjustEl.classList.add("visible"); + timerEl.style.display = "none"; + timerInput.focus(); + timerInput.select(); + }); + + function applyTimerAdjust() { + var val = parseInt(timerInput.value, 10); + if (val && val > 0) timeoutSec = Math.min(val, 600); + timerAdjustEl.classList.remove("visible"); + timerEl.style.display = ""; + resetTimer(); + } + + timerSetBtn.addEventListener("click", function(e) { e.stopPropagation(); applyTimerAdjust(); }); + timerInput.addEventListener("keydown", function(e) { + if (e.key === "Enter") { e.preventDefault(); applyTimerAdjust(); } + if (e.key === "Escape") { timerAdjustEl.classList.remove("visible"); timerEl.style.display = ""; } + e.stopPropagation(); + }); + document.addEventListener("click", function() { + if (timerAdjustEl.classList.contains("visible")) { + timerAdjustEl.classList.remove("visible"); + timerEl.style.display = ""; + } + }); + + // ── Provider ── + + if (globalProvider) { + globalProvider.addEventListener("change", function() { + post("/provider", { provider: globalProvider.value }); + }); + } + + // ── Add search ── + + addSearchInput.addEventListener("keydown", function(e) { + if (e.key !== "Enter") return; + var text = addSearchInput.value.trim(); + if (!text || submitted) return; + e.preventDefault(); + e.stopPropagation(); + + addSearchEl.classList.add("loading"); + addSearchInput.value = ""; + + var card = document.createElement("div"); + card.className = "result-card searching"; + card.innerHTML = + '
' + + '' + + '
' + + '
' + escHtml(text) + '
' + + '
Searching
' + + '
' + + '
'; + resultCardsEl.appendChild(card); + resetTimer(); + + post("/search", { query: text }).then(function(res) { + return res.json(); + }).then(function(data) { + addSearchEl.classList.remove("loading"); + if (!data.ok) { card.remove(); return; } + + card.dataset.qi = data.queryIndex; + + if (data.error) { + card.classList.remove("searching"); + card.classList.add("error"); + card.innerHTML = + '
' + + '' + + '
' + + '
' + escHtml(text) + '
' + + '
Failed
' + + '
' + + '
' + + '
' + escHtml(data.error) + '
'; + return; + } + + card.classList.remove("searching"); + card.classList.add("checked"); + completedCount++; + + populateResultCard(card, data, text); + setupCardInteraction(card); + updateSendButton(); + heroTitle.textContent = completedCount + " Search" + (completedCount !== 1 ? "es" : "") + " Complete"; + heroDesc.textContent = "Review the results and send what you want back to your agent."; + if (heroStatus) heroStatus.textContent = completedCount + " completed"; + resetTimer(); + }).catch(function() { + addSearchEl.classList.remove("loading"); + card.remove(); + }); + }); + + // ── Overlays ── + + function showSuccess(text) { + if (es) { es.close(); es = null; } + successText.textContent = text; + successOverlay.classList.remove("hidden"); + setTimeout(function() { window.close(); }, 800); + } + + function showExpired(text) { + if (es) { es.close(); es = null; } + expiredText.textContent = text; + expiredOverlay.classList.remove("hidden"); + requestAnimationFrame(function() { expiredOverlay.classList.add("visible"); }); + } + + function showError(text) { + errorBanner.textContent = text; + errorBanner.hidden = false; + } + + function onTimeout() { + if (submitted || timerExpired) return; + timerExpired = true; + submitted = true; + showExpired("Time\\u2019s up \\u2014 sending all results to your agent."); + post("/cancel", { reason: "timeout" }); + var count = 5; + closeCountdown.textContent = count; + var iv = setInterval(function() { + count--; + closeCountdown.textContent = count; + if (count <= 0) { clearInterval(iv); window.close(); } + }, 1000); + } + + // ── Create placeholder cards for each query ── + + if (queries.length === 0) { + heroTitle.textContent = "What do you need?"; + heroDesc.textContent = "Search for anything below. Results get sent back to your agent."; + if (heroStatus) heroStatus.textContent = ""; + btnSend.textContent = "No results yet"; + } else { + for (var i = 0; i < queries.length; i++) { + var card = document.createElement("div"); + card.className = "result-card searching"; + card.dataset.qi = i; + card.innerHTML = + '
' + + '' + + '
' + + '
' + escHtml(queries[i]) + '
' + + '
Searching
' + + '
' + + '
'; + resultCardsEl.appendChild(card); + } + } + + // ── SSE ── + + es = new EventSource("/events?session=" + encodeURIComponent(token)); + + es.addEventListener("result", function(e) { + var data = JSON.parse(e.data); + var card = resultCardsEl.querySelector('.result-card[data-qi="' + data.queryIndex + '"]'); + if (!card) return; + + card.classList.remove("searching"); + card.classList.add("checked"); + completedCount++; + + populateResultCard(card, data, data.query || queries[data.queryIndex]); + setupCardInteraction(card); + updateSendButton(); + resetTimer(); + }); + + es.addEventListener("search-error", function(e) { + var data = JSON.parse(e.data); + var card = resultCardsEl.querySelector('.result-card[data-qi="' + data.queryIndex + '"]'); + if (!card) return; + + card.classList.remove("searching"); + card.classList.add("error"); + completedCount++; + + card.innerHTML = + '
' + + '' + + '
' + + '
' + escHtml(data.query || queries[data.queryIndex]) + '
' + + '
Failed
' + + '
' + + '
' + + '
' + escHtml(data.error || "Search failed") + '
'; + + updateSendButton(); + resetTimer(); + }); + + es.addEventListener("done", function() { + searchesDone = true; + if (completedCount > 0) { + heroTitle.textContent = completedCount + " Search" + (completedCount !== 1 ? "es" : "") + " Complete"; + heroDesc.textContent = "Review the results and send what you want back to your agent."; + if (heroStatus) heroStatus.textContent = completedCount + " completed"; + } + updateSendButton(); + resetTimer(); + }); + + es.onerror = function() {}; + + // ── Card interaction ── + + function setupCardInteraction(card) { + var header = card.querySelector(".result-card-header"); + var body = card.querySelector(".result-card-body"); + var cb = card.querySelector("input[type=checkbox]"); + var expandEl = card.querySelector(".result-card-expand"); + + header.addEventListener("click", function(e) { + if (e.target.tagName === "A") return; + if (e.target === cb) { + card.classList.toggle("checked", cb.checked); + updateSendButton(); + return; + } + var isExpanded = body && body.classList.contains("open"); + if (body) body.classList.toggle("open"); + if (expandEl) expandEl.textContent = isExpanded ? "\\u25BC" : "\\u25B2"; + }); + + if (body) { + body.addEventListener("click", function(e) { + e.stopPropagation(); + }); + } + } + + // ── Send button ── + + function getSelectedIndices() { + var indices = []; + var cards = resultCardsEl.querySelectorAll(".result-card"); + cards.forEach(function(card) { + var cb = card.querySelector("input[type=checkbox]"); + if (cb && cb.checked && !cb.disabled) { + indices.push(parseInt(card.dataset.qi, 10)); + } + }); + return indices; + } + + function updateSendButton() { + var sel = getSelectedIndices(); + var hasResults = completedCount > 0; + btnSend.disabled = !hasResults || sel.length === 0; + if (!hasResults) { + btnSend.textContent = searchesDone ? "No results yet" : "Waiting for results\\u2026"; + } else { + btnSend.textContent = "Send Selected (" + sel.length + ")"; + } + btnSendAll.hidden = !searchesDone || !hasResults; + } + + // ── Submit / Cancel ── + + function doSubmit(indices) { + if (submitted) return; + submitted = true; + post("/submit", { selected: indices }).then(function(res) { + if (!res.ok) throw new Error("submit failed"); + showSuccess("Results sent"); + }).catch(function() { + submitted = false; + showError("Failed to send \\u2014 the agent may have moved on"); + }); + } + + function doCancel() { + if (submitted) return; + submitted = true; + post("/cancel", { reason: "user" }).then(function(res) { + if (!res.ok) throw new Error("cancel failed"); + showSuccess("Skipped"); + }).catch(function() { + submitted = false; + showError("Failed \\u2014 the agent may have moved on"); + }); + } + + // ── Button handlers ── + + btnSend.addEventListener("click", function() { + var sel = getSelectedIndices(); + if (sel.length > 0) doSubmit(sel); + }); + + btnSendAll.addEventListener("click", function() { + var all = []; + resultCardsEl.querySelectorAll(".result-card").forEach(function(card) { + var cb = card.querySelector("input[type=checkbox]"); + if (cb && !cb.disabled) all.push(parseInt(card.dataset.qi, 10)); + }); + if (all.length > 0) doSubmit(all); + }); + + // ── Keyboard ── + + document.addEventListener("keydown", function(e) { + if (submitted || timerExpired) return; + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT") return; + + if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + var sel = getSelectedIndices(); + if (sel.length > 0) doSubmit(sel); + } else if (e.key === "Escape") { + e.preventDefault(); + doCancel(); + } else if (e.key === "a" && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + var boxes = resultCardsEl.querySelectorAll("input[type=checkbox]:not(:disabled)"); + var allChecked = true; + boxes.forEach(function(cb) { if (!cb.checked) allChecked = false; }); + boxes.forEach(function(cb) { + cb.checked = !allChecked; + cb.closest(".result-card").classList.toggle("checked", cb.checked); + }); + updateSendButton(); + resetTimer(); + } + }); + + // ── Heartbeat ── + + setInterval(function() { + if (submitted) return; + post("/heartbeat", {}); + }, 10000); + + // ── Focus add-search input when no initial queries ── + + if (queries.length === 0 && addSearchInput) { + addSearchInput.focus(); + } +})();`; diff --git a/extensions/pi-web-access/curator-server.ts b/extensions/pi-web-access/curator-server.ts new file mode 100644 index 0000000..22badc7 --- /dev/null +++ b/extensions/pi-web-access/curator-server.ts @@ -0,0 +1,325 @@ +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import { generateCuratorPage } from "./curator-page.js"; + +const STALE_THRESHOLD_MS = 30000; +const WATCHDOG_INTERVAL_MS = 5000; +const MAX_BODY_SIZE = 64 * 1024; + +type ServerState = "SEARCHING" | "RESULT_SELECTION" | "COMPLETED"; + +export interface CuratorServerOptions { + queries: string[]; + sessionToken: string; + timeout: number; + availableProviders: { perplexity: boolean; gemini: boolean }; + defaultProvider: string; +} + +export interface CuratorServerCallbacks { + onSubmit: (selectedQueryIndices: number[]) => void; + onCancel: (reason: "user" | "timeout" | "stale") => void; + onProviderChange: (provider: string) => void; + onAddSearch: (query: string, queryIndex: number) => Promise<{ answer: string; results: Array<{ title: string; url: string; domain: string }> }>; +} + +export interface CuratorServerHandle { + server: http.Server; + url: string; + close: () => void; + pushResult: (queryIndex: number, data: { answer: string; results: Array<{ title: string; url: string; domain: string }> }) => void; + pushError: (queryIndex: number, error: string) => void; + searchesDone: () => void; +} + +function sendJson(res: ServerResponse, status: number, payload: unknown): void { + res.writeHead(status, { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify(payload)); +} + +function parseJSONBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ""; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_SIZE) { + req.destroy(); + reject(new Error("Request body too large")); + return; + } + body += chunk.toString(); + }); + req.on("end", () => { + try { resolve(JSON.parse(body)); } + catch { reject(new Error("Invalid JSON")); } + }); + req.on("error", reject); + }); +} + +export function startCuratorServer( + options: CuratorServerOptions, + callbacks: CuratorServerCallbacks, +): Promise { + const { queries, sessionToken, timeout, availableProviders, defaultProvider } = options; + let browserConnected = false; + let lastHeartbeatAt = Date.now(); + let completed = false; + let watchdog: NodeJS.Timeout | null = null; + let state: ServerState = "SEARCHING"; + let sseResponse: ServerResponse | null = null; + const sseBuffer: string[] = []; + let nextQueryIndex = queries.length; + + let sseKeepalive: NodeJS.Timeout | null = null; + + const markCompleted = (): boolean => { + if (completed) return false; + completed = true; + state = "COMPLETED"; + if (watchdog) { clearInterval(watchdog); watchdog = null; } + if (sseKeepalive) { clearInterval(sseKeepalive); sseKeepalive = null; } + if (sseResponse) { + try { sseResponse.end(); } catch {} + sseResponse = null; + } + return true; + }; + + const touchHeartbeat = (): void => { + lastHeartbeatAt = Date.now(); + browserConnected = true; + }; + + function validateToken(body: unknown, res: ServerResponse): boolean { + if (!body || typeof body !== "object") { + sendJson(res, 400, { ok: false, error: "Invalid body" }); + return false; + } + if ((body as { token?: string }).token !== sessionToken) { + sendJson(res, 403, { ok: false, error: "Invalid session" }); + return false; + } + return true; + } + + function sendSSE(event: string, data: unknown): void { + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + const res = sseResponse; + if (res && !res.writableEnded && res.socket && !res.socket.destroyed) { + try { + const ok = res.write(payload); + if (!ok) res.once("drain", () => {}); + } catch { + sseBuffer.push(payload); + } + } else { + sseBuffer.push(payload); + } + } + + const pageHtml = generateCuratorPage(queries, sessionToken, timeout, availableProviders, defaultProvider); + + const server = http.createServer(async (req, res) => { + try { + const method = req.method || "GET"; + const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`); + + if (method === "GET" && url.pathname === "/") { + const token = url.searchParams.get("session"); + if (token !== sessionToken) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("Invalid session"); + return; + } + touchHeartbeat(); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(pageHtml); + return; + } + + if (method === "GET" && url.pathname === "/events") { + const token = url.searchParams.get("session"); + if (token !== sessionToken) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("Invalid session"); + return; + } + if (state === "COMPLETED") { + sendJson(res, 409, { ok: false, error: "No events available" }); + return; + } + if (sseResponse) { + try { sseResponse.end(); } catch {} + } + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders(); + if (res.socket) res.socket.setNoDelay(true); + sseResponse = res; + for (const msg of sseBuffer) { + try { res.write(msg); } catch {} + } + sseBuffer.length = 0; + if (sseKeepalive) clearInterval(sseKeepalive); + sseKeepalive = setInterval(() => { + if (sseResponse) { + try { sseResponse.write(":keepalive\n\n"); } catch {} + } + }, 15000); + req.on("close", () => { + if (sseResponse === res) sseResponse = null; + }); + return; + } + + if (method === "POST" && url.pathname === "/heartbeat") { + const body = await parseJSONBody(req).catch(() => null); + if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } + if (!validateToken(body, res)) return; + touchHeartbeat(); + sendJson(res, 200, { ok: true }); + return; + } + + if (method === "POST" && url.pathname === "/provider") { + const body = await parseJSONBody(req).catch(() => null); + if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } + if (!validateToken(body, res)) return; + const { provider } = body as { provider?: string }; + if (typeof provider === "string" && provider.length > 0) { + setImmediate(() => callbacks.onProviderChange(provider)); + } + sendJson(res, 200, { ok: true }); + return; + } + + if (method === "POST" && url.pathname === "/search") { + const body = await parseJSONBody(req).catch(() => null); + if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } + if (!validateToken(body, res)) return; + if (state === "COMPLETED") { + sendJson(res, 409, { ok: false, error: "Session closed" }); + return; + } + const { query } = body as { query?: string }; + if (typeof query !== "string" || query.trim().length === 0) { + sendJson(res, 400, { ok: false, error: "Invalid query" }); + return; + } + const qi = nextQueryIndex++; + touchHeartbeat(); + try { + const result = await callbacks.onAddSearch(query.trim(), qi); + sendJson(res, 200, { ok: true, queryIndex: qi, answer: result.answer, results: result.results }); + } catch (err) { + const message = err instanceof Error ? err.message : "Search failed"; + sendJson(res, 200, { ok: true, queryIndex: qi, error: message }); + } + return; + } + + if (method === "POST" && url.pathname === "/submit") { + const body = await parseJSONBody(req).catch(() => null); + if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } + if (!validateToken(body, res)) return; + const { selected } = body as { selected?: number[] }; + if (!Array.isArray(selected) || !selected.every(n => typeof n === "number")) { + sendJson(res, 400, { ok: false, error: "Invalid selection" }); + return; + } + if (state !== "SEARCHING" && state !== "RESULT_SELECTION") { + sendJson(res, 409, { ok: false, error: "Cannot submit in current state" }); + return; + } + if (!markCompleted()) { + sendJson(res, 409, { ok: false, error: "Session closed" }); + return; + } + sendJson(res, 200, { ok: true }); + setImmediate(() => callbacks.onSubmit(selected)); + return; + } + + if (method === "POST" && url.pathname === "/cancel") { + const body = await parseJSONBody(req).catch(() => null); + if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } + if (!validateToken(body, res)) return; + if (!markCompleted()) { + sendJson(res, 200, { ok: true }); + return; + } + const { reason } = body as { reason?: string }; + sendJson(res, 200, { ok: true }); + const cancelReason = reason === "timeout" ? "timeout" : "user"; + setImmediate(() => callbacks.onCancel(cancelReason)); + return; + } + + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found"); + } catch (err) { + const message = err instanceof Error ? err.message : "Server error"; + sendJson(res, 500, { ok: false, error: message }); + } + }); + + return new Promise((resolve, reject) => { + const onError = (err: Error) => { + reject(new Error(`Curator server failed to start: ${err.message}`)); + }; + + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("Curator server: invalid address")); + return; + } + const url = `http://localhost:${addr.port}/?session=${sessionToken}`; + + watchdog = setInterval(() => { + if (completed || !browserConnected) return; + if (Date.now() - lastHeartbeatAt <= STALE_THRESHOLD_MS) return; + if (!markCompleted()) return; + setImmediate(() => callbacks.onCancel("stale")); + }, WATCHDOG_INTERVAL_MS); + + resolve({ + server, + url, + close: () => { + const wasOpen = markCompleted(); + try { server.close(); } catch {} + if (wasOpen) { + setImmediate(() => callbacks.onCancel("stale")); + } + }, + pushResult: (queryIndex, data) => { + if (completed) return; + sendSSE("result", { queryIndex, query: queries[queryIndex] ?? "", ...data }); + }, + pushError: (queryIndex, error) => { + if (completed) return; + sendSSE("search-error", { queryIndex, query: queries[queryIndex] ?? "", error }); + }, + searchesDone: () => { + if (completed) return; + sendSSE("done", {}); + state = "RESULT_SELECTION"; + }, + }); + }); + }); +} diff --git a/extensions/pi-web-access/extract.ts b/extensions/pi-web-access/extract.ts new file mode 100644 index 0000000..a859898 --- /dev/null +++ b/extensions/pi-web-access/extract.ts @@ -0,0 +1,560 @@ +import { Readability } from "@mozilla/readability"; +import { parseHTML } from "linkedom"; +import TurndownService from "turndown"; +import pLimit from "p-limit"; +import { activityMonitor } from "./activity.js"; +import { extractRSCContent } from "./rsc-extract.js"; +import { extractPDFToMarkdown, isPDF } from "./pdf-extract.js"; +import { extractGitHub } from "./github-extract.js"; +import { isYouTubeURL, isYouTubeEnabled, extractYouTube, extractYouTubeFrame, extractYouTubeFrames, getYouTubeStreamInfo } from "./youtube-extract.js"; +import { extractWithUrlContext, extractWithGeminiWeb } from "./gemini-url-context.js"; +import { isVideoFile, extractVideo, extractVideoFrame, getLocalVideoDuration } from "./video-extract.js"; +import { formatSeconds } from "./utils.js"; + +const DEFAULT_TIMEOUT_MS = 30000; +const CONCURRENT_LIMIT = 3; + +const NON_RECOVERABLE_ERRORS = ["Unsupported content type", "Response too large"]; +const MIN_USEFUL_CONTENT = 500; + +const turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", +}); + +const fetchLimit = pLimit(CONCURRENT_LIMIT); + +export interface VideoFrame { + data: string; + mimeType: string; + timestamp: string; +} + +export type FrameData = { data: string; mimeType: string }; +export type FrameResult = FrameData | { error: string }; + +export interface ExtractedContent { + url: string; + title: string; + content: string; + error: string | null; + thumbnail?: { data: string; mimeType: string }; + frames?: VideoFrame[]; + duration?: number; +} + +export interface ExtractOptions { + timeoutMs?: number; + forceClone?: boolean; + prompt?: string; + timestamp?: string; + frames?: number; + model?: string; +} + +const JINA_READER_BASE = "https://r.jina.ai/"; +const JINA_TIMEOUT_MS = 30000; + +async function extractWithJinaReader( + url: string, + signal?: AbortSignal, +): Promise { + const jinaUrl = JINA_READER_BASE + url; + + const activityId = activityMonitor.logStart({ type: "api", query: `jina: ${url}` }); + + try { + const res = await fetch(jinaUrl, { + headers: { + "Accept": "text/markdown", + "X-No-Cache": "true", + }, + signal: AbortSignal.any([ + AbortSignal.timeout(JINA_TIMEOUT_MS), + ...(signal ? [signal] : []), + ]), + }); + + if (!res.ok) { + activityMonitor.logComplete(activityId, res.status); + return null; + } + + const content = await res.text(); + activityMonitor.logComplete(activityId, res.status); + + const contentStart = content.indexOf("Markdown Content:"); + if (contentStart < 0) { + return null; + } + + const markdownPart = content.slice(contentStart + 17).trim(); // 17 = "Markdown Content:".length + + // Check for failed JS rendering or minimal content + if (markdownPart.length < 100 || + markdownPart.startsWith("Loading...") || + markdownPart.startsWith("Please enable JavaScript")) { + return null; + } + + const title = extractHeadingTitle(markdownPart) ?? (new URL(url).pathname.split("/").pop() || url); + return { url, title, content: markdownPart, error: null }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.toLowerCase().includes("abort")) { + activityMonitor.logComplete(activityId, 0); + } else { + activityMonitor.logError(activityId, message); + } + return null; + } +} + +function parseTimestamp(ts: string): number | null { + const num = Number(ts); + if (!isNaN(num) && num >= 0) return Math.floor(num); + const parts = ts.split(":").map(Number); + if (parts.some(p => isNaN(p) || p < 0)) return null; + if (parts.length === 3) return Math.floor(parts[0] * 3600 + parts[1] * 60 + parts[2]); + if (parts.length === 2) return Math.floor(parts[0] * 60 + parts[1]); + return null; +} + +type TimestampSpec = { type: "single"; seconds: number } | { type: "range"; start: number; end: number }; + +function parseTimestampSpec(ts: string): TimestampSpec | null { + const dashIdx = ts.indexOf("-", 1); + if (dashIdx > 0) { + const start = parseTimestamp(ts.slice(0, dashIdx)); + const end = parseTimestamp(ts.slice(dashIdx + 1)); + if (start !== null && end !== null && end > start) return { type: "range", start, end }; + } + const seconds = parseTimestamp(ts); + return seconds !== null ? { type: "single", seconds } : null; +} + +const DEFAULT_RANGE_FRAMES = 6; +const MIN_FRAME_INTERVAL = 5; + +function computeRangeTimestamps(start: number, end: number, maxFrames: number = DEFAULT_RANGE_FRAMES): number[] { + if (maxFrames <= 1) return [start]; + const duration = end - start; + const idealInterval = duration / (maxFrames - 1); + if (idealInterval < MIN_FRAME_INTERVAL) { + const timestamps: number[] = []; + for (let t = start; t <= end && timestamps.length < maxFrames; t += MIN_FRAME_INTERVAL) { + timestamps.push(t); + } + return timestamps; + } + return Array.from({ length: maxFrames }, (_, i) => Math.round(start + i * idealInterval)); +} + +function buildFrameResult( + url: string, label: string, requestedCount: number, + frames: VideoFrame[], error: string | null, duration?: number, +): ExtractedContent { + if (frames.length === 0) { + const msg = error ?? "Frame extraction failed"; + return { url, title: `Frames ${label} (0/${requestedCount})`, content: msg, error: msg }; + } + return { + url, + title: `Frames ${label} (${frames.length}/${requestedCount})`, + content: `${frames.length} frames extracted from ${label}`, + error: null, + frames, + duration, + }; +} + +async function extractLocalFrames( + filePath: string, timestamps: number[], +): Promise<{ frames: VideoFrame[]; error: string | null }> { + const results = await Promise.all(timestamps.map(async (t) => { + const frame = await extractVideoFrame(filePath, t); + if ("error" in frame) return { error: frame.error }; + return { ...frame, timestamp: formatSeconds(t) }; + })); + const frames = results.filter((f): f is VideoFrame => "data" in f); + const firstError = results.find((f): f is { error: string } => "error" in f); + return { frames, error: frames.length === 0 && firstError ? firstError.error : null }; +} + +export async function extractContent( + url: string, + signal?: AbortSignal, + options?: ExtractOptions, +): Promise { + if (signal?.aborted) { + return { url, title: "", content: "", error: "Aborted" }; + } + + if (options?.frames && !options.timestamp) { + const frameCount = options.frames; + const ytInfo = isYouTubeURL(url); + if (ytInfo.isYouTube && ytInfo.videoId) { + const streamInfo = await getYouTubeStreamInfo(ytInfo.videoId); + if ("error" in streamInfo) { + return { url, title: "Frames", content: streamInfo.error, error: streamInfo.error }; + } + if (streamInfo.duration === null) { + const error = "Cannot determine video duration. Use a timestamp range instead."; + return { url, title: "Frames", content: error, error }; + } + const dur = Math.floor(streamInfo.duration); + const timestamps = computeRangeTimestamps(0, dur, frameCount); + const result = await extractYouTubeFrames(ytInfo.videoId, timestamps, streamInfo); + const label = `${formatSeconds(0)}-${formatSeconds(dur)}`; + return buildFrameResult(url, label, timestamps.length, result.frames, result.error, streamInfo.duration); + } + + const videoInfo = isVideoFile(url); + if (videoInfo) { + const durationResult = await getLocalVideoDuration(videoInfo.absolutePath); + if (typeof durationResult !== "number") { + return { url, title: "Frames", content: durationResult.error, error: durationResult.error }; + } + const dur = Math.floor(durationResult); + const timestamps = computeRangeTimestamps(0, dur, frameCount); + const result = await extractLocalFrames(videoInfo.absolutePath, timestamps); + const label = `${formatSeconds(0)}-${formatSeconds(dur)}`; + return buildFrameResult(url, label, timestamps.length, result.frames, result.error, durationResult); + } + + return { url, title: "", content: "", error: "Frame extraction only works with YouTube and local video files" }; + } + + if (options?.timestamp) { + const spec = parseTimestampSpec(options.timestamp); + if (spec) { + const frameCount = options.frames; + const ytInfo = isYouTubeURL(url); + if (ytInfo.isYouTube && ytInfo.videoId) { + const streamInfo = await getYouTubeStreamInfo(ytInfo.videoId); + if ("error" in streamInfo) { + if (spec.type === "range") { + const label = `${formatSeconds(spec.start)}-${formatSeconds(spec.end)}`; + return { url, title: `Frames ${label}`, content: streamInfo.error, error: streamInfo.error }; + } + if (frameCount) { + const end = spec.seconds + (frameCount - 1) * MIN_FRAME_INTERVAL; + const label = `${formatSeconds(spec.seconds)}-${formatSeconds(end)}`; + return { url, title: `Frames ${label}`, content: streamInfo.error, error: streamInfo.error }; + } + return { url, title: `Frame at ${options.timestamp}`, content: streamInfo.error, error: streamInfo.error }; + } + + if (spec.type === "range") { + const label = `${formatSeconds(spec.start)}-${formatSeconds(spec.end)}`; + if (streamInfo.duration !== null && spec.end > streamInfo.duration) { + const error = `Timestamp ${formatSeconds(spec.end)} exceeds video duration (${formatSeconds(Math.floor(streamInfo.duration))})`; + return { url, title: `Frames ${label}`, content: error, error }; + } + const timestamps = frameCount + ? computeRangeTimestamps(spec.start, spec.end, frameCount) + : computeRangeTimestamps(spec.start, spec.end); + const result = await extractYouTubeFrames(ytInfo.videoId, timestamps, streamInfo); + return buildFrameResult(url, label, timestamps.length, result.frames, result.error, result.duration ?? undefined); + } + + if (frameCount) { + const end = spec.seconds + (frameCount - 1) * MIN_FRAME_INTERVAL; + const label = `${formatSeconds(spec.seconds)}-${formatSeconds(end)}`; + if (streamInfo.duration !== null && end > streamInfo.duration) { + const error = `Timestamp ${formatSeconds(end)} exceeds video duration (${formatSeconds(Math.floor(streamInfo.duration))})`; + return { url, title: `Frames ${label}`, content: error, error }; + } + const timestamps = computeRangeTimestamps(spec.seconds, end, frameCount); + const result = await extractYouTubeFrames(ytInfo.videoId, timestamps, streamInfo); + return buildFrameResult(url, label, timestamps.length, result.frames, result.error, result.duration ?? undefined); + } + + if (streamInfo.duration !== null && spec.seconds > streamInfo.duration) { + const error = `Timestamp ${formatSeconds(spec.seconds)} exceeds video duration (${formatSeconds(Math.floor(streamInfo.duration))})`; + return { url, title: `Frame at ${options.timestamp}`, content: error, error }; + } + const frame = await extractYouTubeFrame(ytInfo.videoId, spec.seconds, streamInfo); + if ("error" in frame) { + return { url, title: `Frame at ${options.timestamp}`, content: frame.error, error: frame.error }; + } + return { url, title: `Frame at ${options.timestamp}`, content: `Video frame at ${options.timestamp}`, error: null, thumbnail: frame }; + } + + const videoInfo = isVideoFile(url); + if (videoInfo) { + if (spec.type === "range") { + const timestamps = frameCount + ? computeRangeTimestamps(spec.start, spec.end, frameCount) + : computeRangeTimestamps(spec.start, spec.end); + const result = await extractLocalFrames(videoInfo.absolutePath, timestamps); + const label = `${formatSeconds(spec.start)}-${formatSeconds(spec.end)}`; + return buildFrameResult(url, label, timestamps.length, result.frames, result.error); + } + + if (frameCount) { + const end = spec.seconds + (frameCount - 1) * MIN_FRAME_INTERVAL; + const timestamps = computeRangeTimestamps(spec.seconds, end, frameCount); + const result = await extractLocalFrames(videoInfo.absolutePath, timestamps); + const label = `${formatSeconds(spec.seconds)}-${formatSeconds(end)}`; + return buildFrameResult(url, label, timestamps.length, result.frames, result.error); + } + + const frame = await extractVideoFrame(videoInfo.absolutePath, spec.seconds); + if ("error" in frame) { + return { url, title: `Frame at ${options.timestamp}`, content: frame.error, error: frame.error }; + } + return { url, title: `Frame at ${options.timestamp}`, content: `Video frame at ${options.timestamp}`, error: null, thumbnail: frame }; + } + } + } + + const videoInfo = isVideoFile(url); + if (videoInfo) { + const result = await extractVideo(videoInfo, signal, options); + return result ?? { url, title: "", content: "", error: "Video analysis requires Gemini access. Either:\n 1. Sign into gemini.google.com in Chrome (free, uses cookies)\n 2. Set GEMINI_API_KEY in ~/.pi/web-search.json" }; + } + + try { + new URL(url); + } catch { + return { url, title: "", content: "", error: "Invalid URL" }; + } + + try { + const ghResult = await extractGitHub(url, signal, options?.forceClone); + if (ghResult) return ghResult; + } catch {} + + const ytInfo = isYouTubeURL(url); + if (ytInfo.isYouTube && isYouTubeEnabled()) { + try { + const ytResult = await extractYouTube(url, signal, options?.prompt, options?.model); + if (ytResult) return ytResult; + } catch {} + return { + url, + title: "", + content: "", + error: "Could not extract YouTube video content. Sign into Google in Chrome for automatic access, or set GEMINI_API_KEY.", + }; + } + + const httpResult = await extractViaHttp(url, signal, options); + + if (!httpResult.error || signal?.aborted) return httpResult; + if (NON_RECOVERABLE_ERRORS.some(prefix => httpResult.error!.startsWith(prefix))) return httpResult; + + const jinaResult = await extractWithJinaReader(url, signal); + if (jinaResult) return jinaResult; + + const geminiResult = await extractWithUrlContext(url, signal) + ?? await extractWithGeminiWeb(url, signal); + + if (geminiResult) return geminiResult; + + const guidance = [ + httpResult.error, + "", + "Fallback options:", + " \u2022 Set GEMINI_API_KEY in ~/.pi/web-search.json", + " \u2022 Sign into gemini.google.com in Chrome", + " \u2022 Use web_search to find content about this topic", + ].join("\n"); + return { ...httpResult, error: guidance }; +} + +function isLikelyJSRendered(html: string): boolean { + // Extract body content + const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); + if (!bodyMatch) return false; + + const bodyHtml = bodyMatch[1]; + + // Strip tags to get text content + const textContent = bodyHtml + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, "") + .replace(/\s+/g, " ") + .trim(); + + // Count scripts + const scriptCount = (html.match(/ tags. + */ + +export interface RSCExtractResult { + title: string; + content: string; +} + +export function extractRSCContent(html: string): RSCExtractResult | null { + if (!html.includes("self.__next_f.push")) { + return null; + } + + // Parse all RSC chunks into a map + const chunkMap = new Map(); + const scriptRegex = /` tags - - Reconstructs markdown with headings, tables, code blocks, links - - Handles chunk references and nested components - - Falls back to RSC extraction when Readability fails -- Content-type validation rejects binary files (images, PDFs, audio, video, zip) -- 5MB response size limit (checked via Content-Length header) to prevent memory issues - -### Fixed - -- `fetch_content` now handles plain text URLs (raw.githubusercontent.com, gist.githubusercontent.com, any text/plain response) instead of failing with "Could not extract readable content" - -## [0.2.0] - 2026-01-11 - -### Added - -- Activity monitor widget (`Ctrl+Shift+O`) showing live request/response activity - - Displays last 10 API calls and URL fetches with status codes and timing - - Shows rate limit usage and reset countdown - - Live updates as requests complete - - Auto-clears on session switch - -### Changed - -- Refactored activity tracking into dedicated `activity.ts` module - -## [0.1.0] - 2026-01-06 - -Initial release. Designed for pi v0.37.3. - -### Added - -- `web_search` tool - Search via Perplexity AI with synthesized answers and citations - - Single or batch queries (parallel execution) - - Recency filter (day/week/month/year) - - Domain filter (include or exclude) - - Optional async content fetching with agent notification -- `fetch_content` tool - Fetch and extract readable content from URLs - - Single URL returns content directly - - Multiple URLs store for retrieval via `get_search_content` - - Concurrent fetching (3 max) with 30s timeout -- `get_search_content` tool - Retrieve stored search results or fetched content - - Access by response ID, URL, query, or index -- `/search` command - Interactive browser for stored results -- TUI rendering with progress bars, URL lists, and expandable previews -- Session-aware storage with 1-hour TTL -- Rate limiting (10 req/min for Perplexity API) -- Config file support (`~/.pi/web-search.json`) -- Content extraction via Readability + Turndown (max 10k chars) -- Proper session isolation - pending fetches abort on session switch -- URL validation before fetch attempts -- Defensive JSON parsing for API responses diff --git a/extensions/pi-web-access/README.md b/extensions/pi-web-access/README.md deleted file mode 100644 index a05c61b..0000000 --- a/extensions/pi-web-access/README.md +++ /dev/null @@ -1,334 +0,0 @@ -

- pi-web-access -

- -# Pi Web Access - -**Web search, content extraction, and video understanding for Pi agent. Zero config with Chrome, or bring your own API keys.** - -[![npm version](https://img.shields.io/npm/v/pi-web-access?style=for-the-badge)](https://www.npmjs.com/package/pi-web-access) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) -[![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux%20%7C%20Windows*-blue?style=for-the-badge)]() - -https://github.com/user-attachments/assets/cac6a17a-1eeb-4dde-9818-cdf85d8ea98f - -## Why Pi Web Access - -**Zero Config** — Signed into Google in Chrome? That's it. The extension reads your Chrome session cookies to access Gemini directly. No API keys, no setup, no subscriptions. - -**Video Understanding** — Point it at a YouTube video or local screen recording and ask questions about what's on screen. Full transcripts, visual descriptions, and frame extraction at exact timestamps. - -**Smart Fallbacks** — Every capability has a fallback chain. Search tries Perplexity, then Gemini API, then Gemini Web. YouTube tries Gemini Web, then API, then Perplexity. Blocked pages retry through Jina Reader and Gemini extraction. Something always works. - -**GitHub Cloning** — GitHub URLs are cloned locally instead of scraped. The agent gets real file contents and a local path to explore, not rendered HTML. - -## Install - -```bash -pi install npm:pi-web-access -``` - -If you're not signed into Chrome, or prefer a different provider, add API keys to `~/.pi/web-search.json`: - -```json -{ - "perplexityApiKey": "pplx-...", - "geminiApiKey": "AIza..." -} -``` - -You can configure one or both. In `auto` mode (default), `web_search` tries Perplexity first, then Gemini API, then Gemini Web. - -Optional dependencies for video frame extraction: - -```bash -brew install ffmpeg # frame extraction, video thumbnails, local video duration -brew install yt-dlp # YouTube stream URLs for frame extraction -``` - -Without these, video content analysis (transcripts, visual descriptions via Gemini) still works. The binaries are only needed for extracting individual frames as images. - -Requires Pi v0.37.3+. - -## Quick Start - -```typescript -// Search the web -web_search({ query: "TypeScript best practices 2025" }) - -// Fetch a page -fetch_content({ url: "https://docs.example.com/guide" }) - -// Clone a GitHub repo -fetch_content({ url: "https://github.com/owner/repo" }) - -// Understand a YouTube video -fetch_content({ url: "https://youtube.com/watch?v=abc", prompt: "What libraries are shown?" }) - -// Analyze a screen recording -fetch_content({ url: "/path/to/recording.mp4", prompt: "What error appears on screen?" }) -``` - -## Tools - -### web_search - -Search the web via Perplexity AI or Gemini. Returns a synthesized answer with source citations. - -```typescript -web_search({ query: "rust async programming" }) -web_search({ queries: ["query 1", "query 2"] }) -web_search({ query: "latest news", numResults: 10, recencyFilter: "week" }) -web_search({ query: "...", domainFilter: ["github.com"] }) -web_search({ query: "...", provider: "gemini" }) -web_search({ query: "...", includeContent: true }) -web_search({ queries: ["query 1", "query 2"], curate: true }) -``` - -| Parameter | Description | -|-----------|-------------| -| `query` / `queries` | Single query or batch of queries | -| `numResults` | Results per query (default: 5, max: 20) | -| `recencyFilter` | `day`, `week`, `month`, or `year` | -| `domainFilter` | Limit to domains (prefix with `-` to exclude) | -| `provider` | `auto` (default), `perplexity`, or `gemini` | -| `includeContent` | Fetch full page content from sources in background | -| `curate` | Hold results for browser review (default: true for multi-query). Press Ctrl+Shift+S to open browser UI, or wait for countdown to auto-condense and send. Set to false to skip both curation and condensation. | - -### fetch_content - -Fetch URL(s) and extract readable content as markdown. Automatically detects and handles GitHub repos, YouTube videos, PDFs, local video files, and regular web pages. - -```typescript -fetch_content({ url: "https://example.com/article" }) -fetch_content({ urls: ["url1", "url2", "url3"] }) -fetch_content({ url: "https://github.com/owner/repo" }) -fetch_content({ url: "https://youtube.com/watch?v=abc", prompt: "What libraries are shown?" }) -fetch_content({ url: "/path/to/recording.mp4", prompt: "What error appears on screen?" }) -fetch_content({ url: "https://youtube.com/watch?v=abc", timestamp: "23:41-25:00", frames: 4 }) -``` - -| Parameter | Description | -|-----------|-------------| -| `url` / `urls` | Single URL/path or multiple URLs | -| `prompt` | Question to ask about a YouTube video or local video file | -| `timestamp` | Extract frame(s) — single (`"23:41"`), range (`"23:41-25:00"`), or seconds (`"85"`) | -| `frames` | Number of frames to extract (max 12) | -| `forceClone` | Clone GitHub repos that exceed the 350MB size threshold | - -### get_search_content - -Retrieve stored content from previous searches or fetches. Content over 30,000 chars is truncated in tool responses but stored in full for retrieval here. - -```typescript -get_search_content({ responseId: "abc123", urlIndex: 0 }) -get_search_content({ responseId: "abc123", url: "https://..." }) -get_search_content({ responseId: "abc123", query: "original query" }) -``` - -## Capabilities - -### GitHub repos - -GitHub URLs are cloned locally instead of scraped. The agent gets real file contents and a local path to explore with `read` and `bash`. Root URLs return the repo tree + README, `/tree/` paths return directory listings, `/blob/` paths return file contents. - -Repos over 350MB get a lightweight API-based view instead of a full clone (override with `forceClone: true`). Commit SHA URLs are handled via the API. Clones are cached for the session and wiped on session change. Private repos require the `gh` CLI. - -### YouTube videos - -YouTube URLs are processed via Gemini for full video understanding — visual descriptions, transcripts with timestamps, and chapter markers. Pass a `prompt` to ask specific questions about the video. Results include the video thumbnail so the agent gets visual context alongside the transcript. - -Fallback: Gemini Web → Gemini API → Perplexity (text summary only). Handles all URL formats: `/watch?v=`, `youtu.be/`, `/shorts/`, `/live/`, `/embed/`, `/v/`. - -### Local video files - -Pass a file path (`/`, `./`, `../`, or `file://` prefix) to analyze video content via Gemini. Supports MP4, MOV, WebM, AVI, and other common formats up to 50MB. Pass a `prompt` to ask about specific content. If ffmpeg is installed, a thumbnail frame is included alongside the analysis. - -Fallback: Gemini API (Files API upload) → Gemini Web. - -### Video frame extraction - -Use `timestamp` and/or `frames` on any YouTube URL or local video file to extract visual frames as images. - -```typescript -fetch_content({ url: "...", timestamp: "23:41" }) // single frame -fetch_content({ url: "...", timestamp: "23:41-25:00" }) // range, 6 frames -fetch_content({ url: "...", timestamp: "23:41-25:00", frames: 3 }) // range, custom count -fetch_content({ url: "...", timestamp: "23:41", frames: 5 }) // 5 frames at 5s intervals -fetch_content({ url: "...", frames: 6 }) // sample whole video -``` - -Requires `ffmpeg` (and `yt-dlp` for YouTube). Timestamps accept `H:MM:SS`, `MM:SS`, or bare seconds. - -### PDFs - -PDF URLs are extracted as text and saved to `~/Downloads/` as markdown. The agent can then `read` specific sections without loading the full document into context. Text-based extraction only — no OCR. - -### Blocked pages - -When Readability fails or returns only a cookie notice, the extension retries via Jina Reader (handles JS rendering server-side, no API key needed), then Gemini URL Context API, then Gemini Web extraction. Handles SPAs, JS-heavy pages, and anti-bot protections transparently. Also parses Next.js RSC flight data when present. - -## How It Works - -``` -fetch_content(url) - → Video file? Gemini API (Files API) → Gemini Web - → GitHub URL? Clone repo, return file contents + local path - → YouTube URL? Gemini Web → Gemini API → Perplexity - → HTTP fetch → PDF? Extract text, save to ~/Downloads/ - → HTML? Readability → RSC parser → Jina Reader → Gemini fallback - → Text/JSON/Markdown? Return directly -``` - -## Skills - -### librarian - -Bundled research workflow for investigating open-source libraries. Combines GitHub cloning, web search, and git operations (blame, log, show) to produce evidence-backed answers with permalinks. Pi loads it automatically based on your prompt. Also available via `/skill:librarian` with [pi-skill-palette](https://github.com/nicobailon/pi-skill-palette). - -## Commands - -### /websearch - -Open the search curator directly in the browser. Runs searches and lets you review, add, and select results to send back to the agent — no LLM round-trip needed. - -``` -/websearch # empty page, type your own searches -/websearch react hooks, next.js caching # pre-fill with comma-separated queries -``` - -Results get injected into the conversation when you click Send. The agent sees them and can use them immediately. - -### /search - -Browse stored search results interactively. Lists all results from the current session with their response IDs for easy retrieval. - -## Activity Monitor - -Toggle with **Ctrl+Shift+W** to see live request/response activity: - -``` -─── Web Search Activity ──────────────────────────────────── - API "typescript best practices" 200 2.1s ✓ - GET docs.example.com/article 200 0.8s ✓ - GET blog.example.com/post 404 0.3s ✗ -──────────────────────────────────────────────────────────── -``` - -## Configuration - -All config lives in `~/.pi/web-search.json`. Every field is optional. - -```json -{ - "perplexityApiKey": "pplx-...", - "geminiApiKey": "AIza...", - "provider": "perplexity", - "curateWindow": 10, - "autoFilter": true, - "githubClone": { - "enabled": true, - "maxRepoSizeMB": 350, - "cloneTimeoutSeconds": 30, - "clonePath": "/tmp/pi-github-repos" - }, - "youtube": { - "enabled": true, - "preferredModel": "gemini-3-flash-preview" - }, - "video": { - "enabled": true, - "preferredModel": "gemini-3-flash-preview", - "maxSizeMB": 50 - }, - "shortcuts": { - "curate": "ctrl+shift+s", - "activity": "ctrl+shift+w" - } -} -``` - -`GEMINI_API_KEY` and `PERPLEXITY_API_KEY` env vars take precedence over config file values. `provider` sets the default search provider: `"perplexity"` or `"gemini"`. This is also updated automatically when you change the provider in the curator UI. `curateWindow` controls how many seconds multi-query searches wait before auto-sending results (default: 10). During the countdown, press Ctrl+Shift+S to open the browser curator. Set to 0 to always send immediately (Ctrl+Shift+S still works during the search itself). - -### Shortcuts - -Both shortcuts are configurable via `~/.pi/web-search.json`: - -```json -{ - "shortcuts": { - "curate": "ctrl+shift+s", - "activity": "ctrl+shift+w" - } -} -``` - -Values use the same format as pi keybindings (e.g. `ctrl+s`, `ctrl+shift+s`, `alt+r`). Changes take effect on next pi restart. - -### Auto-Condense - -Multi-query searches are automatically condensed into a deduplicated briefing when the countdown expires without manual curation. A single LLM call receives all search results — enriched with preprocessing analysis (URL overlap, answer similarity, source quality tiers) — and produces a concise synthesis organized by topic. Irrelevant or off-topic results are skipped automatically. - -```json -{ - "autoFilter": { - "enabled": true, - "model": "anthropic/claude-haiku-4-5", - "prompt": "You are a research assistant..." - } -} -``` - -| Field | Description | -|-------|-------------| -| `enabled` | `true` to enable, `false` to disable. Omit `autoFilter` entirely to enable with defaults. | -| `model` | LLM model in `provider/model` format. Uses pi's model registry for auth. Default: `anthropic/claude-haiku-4-5`. | -| `prompt` | System prompt for the condenser. Should instruct the model to synthesize, deduplicate, and cite sources. Omit to use the built-in prompt. | - -Shorthand: `"autoFilter": true` or `"autoFilter": false` for enable/disable without customizing model or prompt. - -The model uses pi's model registry, so any configured provider works — including custom gateways, OAuth tokens, and API keys. If the model isn't found in the registry or has no credentials, condensation is silently skipped. - -The `web_search` tool also accepts an optional `context` parameter — a brief description of the user's current task or goal. When provided, the condenser uses it to focus the briefing (e.g., "building a Shopify checkout extension" helps it emphasize relevant findings over general noise). - -Set `"enabled": false` under any feature to disable it. Config changes require a Pi restart. - -Rate limits: Perplexity is capped at 10 requests/minute (client-side). Content fetches run 3 concurrent with a 30s timeout per URL. - -## Limitations - -- Chrome cookie extraction is macOS-only — other platforms fall through to API keys. First-time access may trigger a Keychain dialog. -- YouTube private/age-restricted videos may fail on all extraction paths. -- Gemini can process videos up to ~1 hour; longer videos may be truncated. -- PDFs are text-extracted only (no OCR for scanned documents). -- GitHub branch names with slashes may misresolve file paths; the clone still works and the agent can navigate manually. -- Non-code GitHub URLs (issues, PRs, wiki) fall through to normal web extraction. - -
-Files - -| File | Purpose | -|------|---------| -| `index.ts` | Extension entry, tool definitions, commands, widget | -| `curator-page.ts` | HTML/CSS/JS generation for the curator UI with markdown rendering | -| `curator-server.ts` | Ephemeral HTTP server with SSE streaming and state machine | -| `search-filter.ts` | Auto-condense pipeline — preprocessing, LLM condensation, and post-processing for multi-query results | -| `extract.ts` | URL/file path routing, HTTP extraction, fallback orchestration | -| `gemini-search.ts` | Search routing across Perplexity, Gemini API, Gemini Web | -| `gemini-url-context.ts` | Gemini URL Context + Web extraction fallbacks | -| `gemini-web.ts` | Gemini Web client (cookie auth, StreamGenerate) | -| `gemini-api.ts` | Gemini REST API client (generateContent) | -| `chrome-cookies.ts` | macOS Chrome cookie extraction (Keychain + SQLite) | -| `youtube-extract.ts` | YouTube detection, three-tier extraction, frame extraction | -| `video-extract.ts` | Local video detection, Files API upload, Gemini analysis | -| `github-extract.ts` | GitHub URL parsing, clone cache, content generation | -| `github-api.ts` | GitHub API fallback for large repos and commit SHAs | -| `perplexity.ts` | Perplexity API client with rate limiting | -| `pdf-extract.ts` | PDF text extraction, saves to markdown | -| `rsc-extract.ts` | RSC flight data parser for Next.js pages | -| `utils.ts` | Shared formatting and error helpers | -| `storage.ts` | Session-aware result storage | -| `activity.ts` | Activity tracking for the observability widget | -| `skills/librarian/` | Bundled skill for library research | - -
diff --git a/extensions/pi-web-access/activity.ts b/extensions/pi-web-access/activity.ts deleted file mode 100644 index 9c18304..0000000 --- a/extensions/pi-web-access/activity.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Types -export interface ActivityEntry { - id: string; - type: "api" | "fetch"; - startTime: number; - endTime?: number; - - // For API calls - query?: string; - - // For URL fetches - url?: string; - - // Result - status is number (HTTP code) or null (pending/network error) - status: number | null; - error?: string; -} - -export interface RateLimitInfo { - used: number; - max: number; - oldestTimestamp: number | null; - windowMs: number; -} - -export class ActivityMonitor { - private entries: ActivityEntry[] = []; - private readonly maxEntries = 10; - private listeners = new Set<() => void>(); - private rateLimitInfo: RateLimitInfo = { used: 0, max: 10, oldestTimestamp: null, windowMs: 60000 }; - private nextId = 1; - - logStart(partial: Omit): string { - const id = `act-${this.nextId++}`; - const entry: ActivityEntry = { - ...partial, - id, - startTime: Date.now(), - status: null, - }; - this.entries.push(entry); - if (this.entries.length > this.maxEntries) { - this.entries.shift(); - } - this.notify(); - return id; - } - - logComplete(id: string, status: number): void { - const entry = this.entries.find((e) => e.id === id); - if (entry) { - entry.endTime = Date.now(); - entry.status = status; - this.notify(); - } - } - - logError(id: string, error: string): void { - const entry = this.entries.find((e) => e.id === id); - if (entry) { - entry.endTime = Date.now(); - entry.error = error; - this.notify(); - } - } - - getEntries(): readonly ActivityEntry[] { - return this.entries; - } - - getRateLimitInfo(): RateLimitInfo { - return this.rateLimitInfo; - } - - updateRateLimit(info: RateLimitInfo): void { - this.rateLimitInfo = info; - this.notify(); - } - - onUpdate(callback: () => void): () => void { - this.listeners.add(callback); - return () => this.listeners.delete(callback); - } - - clear(): void { - this.entries = []; - this.rateLimitInfo = { used: 0, max: 10, oldestTimestamp: null, windowMs: 60000 }; - this.notify(); - } - - private notify(): void { - for (const cb of this.listeners) { - try { - cb(); - } catch { - /* ignore */ - } - } - } -} - -export const activityMonitor = new ActivityMonitor(); diff --git a/extensions/pi-web-access/chrome-cookies.ts b/extensions/pi-web-access/chrome-cookies.ts deleted file mode 100644 index c71b030..0000000 --- a/extensions/pi-web-access/chrome-cookies.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { execFile } from "node:child_process"; -import { pbkdf2Sync, createDecipheriv } from "node:crypto"; -import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir, homedir, platform } from "node:os"; -import { join } from "node:path"; - -export type CookieMap = Record; - -const GOOGLE_ORIGINS = [ - "https://gemini.google.com", - "https://accounts.google.com", - "https://www.google.com", -]; - -const ALL_COOKIE_NAMES = new Set([ - "__Secure-1PSID", - "__Secure-1PSIDTS", - "__Secure-1PSIDCC", - "__Secure-1PAPISID", - "NID", - "AEC", - "SOCS", - "__Secure-BUCKET", - "__Secure-ENID", - "SID", - "HSID", - "SSID", - "APISID", - "SAPISID", - "__Secure-3PSID", - "__Secure-3PSIDTS", - "__Secure-3PAPISID", - "SIDCC", -]); - -const CHROME_COOKIES_PATH = join( - homedir(), - "Library/Application Support/Google/Chrome/Default/Cookies", -); - -export async function getGoogleCookies(): Promise<{ cookies: CookieMap; warnings: string[] } | null> { - if (platform() !== "darwin") return null; - if (!existsSync(CHROME_COOKIES_PATH)) return null; - - const warnings: string[] = []; - - const password = await readKeychainPassword(); - if (!password) { - warnings.push("Could not read Chrome Safe Storage password from Keychain"); - return { cookies: {}, warnings }; - } - - const key = pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1"); - const tempDir = mkdtempSync(join(tmpdir(), "pi-chrome-cookies-")); - - try { - const tempDb = join(tempDir, "Cookies"); - copyFileSync(CHROME_COOKIES_PATH, tempDb); - copySidecar(CHROME_COOKIES_PATH, tempDb, "-wal"); - copySidecar(CHROME_COOKIES_PATH, tempDb, "-shm"); - - const metaVersion = await readMetaVersion(tempDb); - const stripHash = metaVersion >= 24; - - const hosts = GOOGLE_ORIGINS.map((o) => new URL(o).hostname); - const rows = await queryCookieRows(tempDb, hosts); - if (!rows) { - warnings.push("Failed to query Chrome cookie database"); - return { cookies: {}, warnings }; - } - - const cookies: CookieMap = {}; - for (const row of rows) { - const name = row.name as string; - if (!ALL_COOKIE_NAMES.has(name)) continue; - if (cookies[name]) continue; - - let value = typeof row.value === "string" && row.value.length > 0 ? row.value : null; - if (!value) { - const encrypted = row.encrypted_value; - if (encrypted instanceof Uint8Array) { - value = decryptCookieValue(encrypted, key, stripHash); - } - } - if (value) cookies[name] = value; - } - - return { cookies, warnings }; - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -} - -function decryptCookieValue(encrypted: Uint8Array, key: Buffer, stripHash: boolean): string | null { - const buf = Buffer.from(encrypted); - if (buf.length < 3) return null; - - const prefix = buf.subarray(0, 3).toString("utf8"); - if (!/^v\d\d$/.test(prefix)) return null; - - const ciphertext = buf.subarray(3); - if (!ciphertext.length) return ""; - - try { - const iv = Buffer.alloc(16, 0x20); - const decipher = createDecipheriv("aes-128-cbc", key, iv); - decipher.setAutoPadding(false); - const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); - const unpadded = removePkcs7Padding(plaintext); - const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded; - const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - let i = 0; - while (i < decoded.length && decoded.charCodeAt(i) < 0x20) i++; - return decoded.slice(i); - } catch { - return null; - } -} - -function removePkcs7Padding(buf: Buffer): Buffer { - if (!buf.length) return buf; - const padding = buf[buf.length - 1]; - if (!padding || padding > 16) return buf; - return buf.subarray(0, buf.length - padding); -} - -function readKeychainPassword(): Promise { - return new Promise((resolve) => { - execFile( - "security", - ["find-generic-password", "-w", "-a", "Chrome", "-s", "Chrome Safe Storage"], - { timeout: 5000 }, - (err, stdout) => { - if (err) { resolve(null); return; } - resolve(stdout.trim() || null); - }, - ); - }); -} - -let sqliteModule: typeof import("node:sqlite") | null = null; - -async function importSqlite(): Promise { - if (sqliteModule) return sqliteModule; - const orig = process.emitWarning.bind(process); - process.emitWarning = ((warning: string | Error, ...args: unknown[]) => { - const msg = typeof warning === "string" ? warning : warning?.message ?? ""; - if (msg.includes("SQLite is an experimental feature")) return; - return (orig as Function)(warning, ...args); - }) as typeof process.emitWarning; - try { - sqliteModule = await import("node:sqlite"); - return sqliteModule; - } catch { - return null; - } finally { - process.emitWarning = orig; - } -} - -function supportsReadBigInts(): boolean { - const [major, minor] = process.versions.node.split(".").map(Number); - if (major > 24) return true; - if (major < 24) return false; - return minor >= 4; -} - -async function readMetaVersion(dbPath: string): Promise { - const sqlite = await importSqlite(); - if (!sqlite) return 0; - const opts: Record = { readOnly: true }; - if (supportsReadBigInts()) opts.readBigInts = true; - const db = new sqlite.DatabaseSync(dbPath, opts); - try { - const rows = db.prepare("SELECT value FROM meta WHERE key = 'version'").all() as Array>; - const val = rows[0]?.value; - if (typeof val === "number") return Math.floor(val); - if (typeof val === "bigint") return Number(val); - if (typeof val === "string") return parseInt(val, 10) || 0; - return 0; - } catch { - return 0; - } finally { - db.close(); - } -} - -async function queryCookieRows( - dbPath: string, - hosts: string[], -): Promise> | null> { - const sqlite = await importSqlite(); - if (!sqlite) return null; - - const clauses: string[] = []; - for (const host of hosts) { - for (const candidate of expandHosts(host)) { - const esc = candidate.replaceAll("'", "''"); - clauses.push(`host_key = '${esc}'`); - clauses.push(`host_key = '.${esc}'`); - clauses.push(`host_key LIKE '%.${esc}'`); - } - } - const where = clauses.join(" OR "); - - const opts: Record = { readOnly: true }; - if (supportsReadBigInts()) opts.readBigInts = true; - const db = new sqlite.DatabaseSync(dbPath, opts); - try { - return db - .prepare( - `SELECT name, value, host_key, encrypted_value FROM cookies WHERE (${where}) ORDER BY expires_utc DESC`, - ) - .all() as Array>; - } catch { - return null; - } finally { - db.close(); - } -} - -function expandHosts(host: string): string[] { - const parts = host.split(".").filter(Boolean); - if (parts.length <= 1) return [host]; - const candidates = new Set(); - candidates.add(host); - for (let i = 1; i <= parts.length - 2; i++) { - const c = parts.slice(i).join("."); - if (c) candidates.add(c); - } - return Array.from(candidates); -} - -function copySidecar(srcDb: string, targetDb: string, suffix: string): void { - const sidecar = `${srcDb}${suffix}`; - if (!existsSync(sidecar)) return; - try { - copyFileSync(sidecar, `${targetDb}${suffix}`); - } catch {} -} diff --git a/extensions/pi-web-access/curator-page.ts b/extensions/pi-web-access/curator-page.ts deleted file mode 100644 index 5441080..0000000 --- a/extensions/pi-web-access/curator-page.ts +++ /dev/null @@ -1,1209 +0,0 @@ -function escapeHtml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -function safeInlineJSON(data: unknown): string { - return JSON.stringify(data) - .replace(//g, "\\u003e") - .replace(/&/g, "\\u0026"); -} - -function buildProviderOptions( - available: { perplexity: boolean; gemini: boolean }, - selected: string, -): string { - const options = [ - { value: "perplexity", label: "Perplexity", disabled: !available.perplexity }, - { value: "gemini", label: "Gemini", disabled: !available.gemini }, - ]; - - return options - .map(o => ``) - .join(""); -} - -export function generateCuratorPage( - queries: string[], - sessionToken: string, - timeout: number, - availableProviders: { perplexity: boolean; gemini: boolean }, - defaultProvider: string, -): string { - const providerOptionsHtml = buildProviderOptions(availableProviders, defaultProvider); - const inlineData = safeInlineJSON({ queries, sessionToken, timeout }); - - return ` - - - - -Curate Search Results - - - - - -`; -} - -const CSS = ` -*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} - -:root { - --bg: #18181e; - --bg-card: #1e1e24; - --bg-elevated: #252530; - --bg-hover: #2b2b37; - --fg: #e0e0e0; - --fg-muted: #909098; - --fg-dim: #606068; - --accent: #8abeb7; - --accent-hover: #9dcec7; - --accent-muted: rgba(138, 190, 183, 0.15); - --accent-subtle: rgba(138, 190, 183, 0.08); - --border: #2a2a34; - --border-muted: #353540; - --border-checked: #8abeb7; - --check-bg: #8abeb7; - --btn-primary: #8abeb7; - --btn-primary-hover: #9dcec7; - --btn-primary-fg: #18181e; - --btn-secondary: #252530; - --btn-secondary-hover: #2b2b37; - --timer-bg: #252530; - --timer-fg: #909098; - --timer-warn-bg: rgba(240, 198, 116, 0.15); - --timer-warn-fg: #f0c674; - --timer-urgent-bg: rgba(204, 102, 102, 0.15); - --timer-urgent-fg: #cc6666; - --overlay-bg: rgba(24, 24, 30, 0.92); - --success: #b5bd68; - --warning: #f0c674; - --font: 'Outfit', system-ui, -apple-system, sans-serif; - --font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif; - --font-mono: 'SF Mono', Consolas, monospace; - --radius: 10px; - --radius-sm: 6px; -} - -@media (prefers-color-scheme: light) { - :root { - --bg: #f5f5f7; - --bg-card: #ffffff; - --bg-elevated: #eeeef0; - --bg-hover: #e4e4e8; - --fg: #1a1a1e; - --fg-muted: #6c6c74; - --fg-dim: #9a9aa2; - --accent: #5f8787; - --accent-hover: #4a7272; - --accent-muted: rgba(95, 135, 135, 0.12); - --accent-subtle: rgba(95, 135, 135, 0.06); - --border: #dcdce0; - --border-muted: #c8c8d0; - --border-checked: #5f8787; - --check-bg: #5f8787; - --btn-primary: #5f8787; - --btn-primary-hover: #4a7272; - --btn-primary-fg: #ffffff; - --btn-secondary: #e4e4e8; - --btn-secondary-hover: #d4d4d8; - --timer-bg: #e4e4e8; - --timer-fg: #6c6c74; - --timer-warn-bg: rgba(217, 119, 6, 0.10); - --timer-warn-fg: #92400e; - --timer-urgent-bg: rgba(175, 95, 95, 0.10); - --timer-urgent-fg: #991b1b; - --overlay-bg: rgba(255, 255, 255, 0.92); - --success: #4d7c0f; - --warning: #b45309; - } -} - -body { - font-family: var(--font); - background: var(--bg); - background-image: radial-gradient(ellipse at 50% 0%, var(--accent-muted) 0%, transparent 60%); - color: var(--fg); - line-height: 1.5; - min-height: 100dvh; - padding-bottom: 72px; -} - -.timer-badge { - position: fixed; - top: 20px; - right: 24px; - z-index: 50; - font-family: var(--font); - font-size: 12px; - font-weight: 600; - font-variant-numeric: tabular-nums; - padding: 5px 14px; - border-radius: 999px; - background: var(--bg-elevated); - color: var(--timer-fg); - border: 1px solid var(--border); - transition: background 0.3s, color 0.3s, border-color 0.3s, opacity 0.3s; - box-shadow: 0 2px 8px rgba(0,0,0,0.2); - cursor: pointer; - user-select: none; - opacity: 0.5; -} -.timer-badge:hover { opacity: 1; } -.timer-badge.active { opacity: 1; } -.timer-badge.warn { - opacity: 1; - background: var(--timer-warn-bg); - color: var(--timer-warn-fg); - border-color: color-mix(in srgb, var(--timer-warn-fg) 30%, transparent); -} -.timer-badge.urgent { - opacity: 1; - background: var(--timer-urgent-bg); - color: var(--timer-urgent-fg); - border-color: color-mix(in srgb, var(--timer-urgent-fg) 30%, transparent); -} -.timer-adjust { - position: fixed; - top: 20px; - right: 24px; - z-index: 51; - display: none; - align-items: center; - gap: 6px; - padding: 4px 6px 4px 12px; - background: var(--bg-elevated); - border: 1px solid var(--accent); - border-radius: 999px; - box-shadow: 0 2px 12px rgba(0,0,0,0.3); -} -.timer-adjust.visible { display: flex; } -.timer-adjust input { - width: 48px; - background: transparent; - border: none; - outline: none; - color: var(--fg); - font-family: var(--font); - font-size: 13px; - font-weight: 600; - font-variant-numeric: tabular-nums; - text-align: center; -} -.timer-adjust-label { font-size: 11px; color: var(--fg-dim); } -.timer-adjust-btn { - font-family: var(--font); - font-size: 11px; - font-weight: 600; - padding: 3px 10px; - border-radius: 999px; - border: none; - background: var(--accent); - color: var(--btn-primary-fg); - cursor: pointer; -} -.timer-adjust-btn:hover { background: var(--accent-hover); } - -main { - max-width: 640px; - margin: 0 auto; - padding: 56px 24px 16px; -} - -.hero { margin-bottom: 28px; } -.hero-kicker { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--accent); - margin-bottom: 8px; -} -.hero-title { - font-family: var(--font-display); - font-size: 40px; - font-weight: 400; - font-style: italic; - letter-spacing: -0.01em; - line-height: 1.1; - color: var(--fg); - margin-bottom: 10px; - text-wrap: balance; -} -.hero-desc { - font-size: 14px; - color: var(--fg-muted); - line-height: 1.5; - margin-bottom: 12px; - max-width: 480px; -} -.hero-meta { - display: flex; - align-items: center; - gap: 10px; - font-size: 13px; - color: var(--fg-dim); -} -.hero-meta-sep { - width: 3px; - height: 3px; - border-radius: 50%; - background: var(--fg-dim); - flex-shrink: 0; -} -#hero-status:empty + .hero-meta-sep { display: none; } -.hero-meta select { - font-family: var(--font); - font-size: 13px; - padding: 3px 8px; - background: transparent; - border: 1px solid transparent; - color: var(--fg-muted); - border-radius: var(--radius-sm); - font-weight: 500; - cursor: pointer; - transition: border-color 0.15s, color 0.15s; -} -.hero-meta select:hover { - border-color: var(--border-muted); - color: var(--fg); -} -.hero-meta select:focus { - outline: none; - border-color: var(--accent); - color: var(--fg); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); -} - -#result-cards { display: flex; flex-direction: column; gap: 8px; } - -.result-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - transition: border-color 0.12s; - box-shadow: 0 1px 2px rgba(0,0,0,0.06); -} -.result-card.checked { border-color: var(--border-checked); } -.result-card.searching { - opacity: 0.7; - border-style: dashed; -} -.result-card.error { border-color: var(--timer-urgent-fg); } - -.result-card-header { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 14px 16px; - cursor: pointer; - user-select: none; - transition: background 0.12s; -} -.result-card-header:hover { background: var(--bg-hover); } - -.result-card-header input[type="checkbox"] { - appearance: none; - width: 16px; - height: 16px; - min-width: 16px; - border: 1.5px solid var(--border-muted); - border-radius: 4px; - margin-top: 2px; - cursor: pointer; - transition: background 0.12s, border-color 0.12s; - display: grid; - place-content: center; -} -.result-card-header input[type="checkbox"]:checked { - background: var(--check-bg); - border-color: var(--check-bg); -} -.result-card-header input[type="checkbox"]:checked::after { - content: ""; - width: 9px; - height: 6px; - border-left: 2px solid var(--btn-primary-fg); - border-bottom: 2px solid var(--btn-primary-fg); - transform: rotate(-45deg); - margin-top: -1px; -} - -.result-card-info { flex: 1; min-width: 0; } - -.result-card-query { - font-size: 14px; - font-weight: 600; - color: var(--fg); - margin-bottom: 2px; -} -.result-card-meta { - font-size: 12px; - color: var(--fg-dim); -} -.result-card-preview { - font-size: 12.5px; - color: var(--fg-muted); - margin-top: 6px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.45; -} - -.result-card-expand { - color: var(--fg-dim); - font-size: 11px; - margin-top: 2px; - flex-shrink: 0; - padding-top: 3px; - transition: color 0.12s; -} -.result-card-header:hover .result-card-expand { color: var(--fg-muted); } - -.result-card-body { - display: none; - border-top: 1px solid var(--border); -} -.result-card-body.open { display: block; } - -.result-card-answer { - padding: 14px 16px; - font-size: 13.5px; - color: var(--fg-muted); - line-height: 1.6; - max-height: 400px; - overflow-y: auto; -} -.result-card-answer h1, -.result-card-answer h2, -.result-card-answer h3, -.result-card-answer h4 { - color: var(--fg); - font-family: var(--font); - font-weight: 600; - margin: 16px 0 6px; - line-height: 1.3; -} -.result-card-answer h1 { font-size: 16px; } -.result-card-answer h2 { font-size: 14.5px; } -.result-card-answer h3 { font-size: 13.5px; } -.result-card-answer h4 { font-size: 13px; color: var(--fg-muted); } -.result-card-answer p { margin: 0 0 10px; } -.result-card-answer p:last-child { margin-bottom: 0; } -.result-card-answer strong { color: var(--fg); font-weight: 600; } -.result-card-answer a { color: var(--accent); text-decoration: none; } -.result-card-answer a:hover { text-decoration: underline; } -.result-card-answer ul, .result-card-answer ol { - margin: 6px 0 10px; - padding-left: 20px; -} -.result-card-answer li { margin-bottom: 4px; } -.result-card-answer li::marker { color: var(--fg-dim); } -.result-card-answer code { - font-family: var(--font-mono); - font-size: 12px; - padding: 1px 5px; - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: 3px; - color: var(--fg); -} -.result-card-answer pre { - margin: 8px 0 12px; - padding: 12px 14px; - background: var(--bg); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow-x: auto; - line-height: 1.45; -} -.result-card-answer pre code { - padding: 0; - background: none; - border: none; - font-size: 12px; - color: var(--fg-muted); -} -.result-card-answer blockquote { - margin: 8px 0; - padding: 8px 14px; - border-left: 3px solid var(--accent); - color: var(--fg-dim); - background: var(--accent-subtle); - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; -} -.result-card-answer table { - width: 100%; - border-collapse: collapse; - margin: 8px 0 12px; - font-size: 12.5px; -} -.result-card-answer th, .result-card-answer td { - padding: 6px 10px; - border: 1px solid var(--border); - text-align: left; -} -.result-card-answer th { - background: var(--bg-elevated); - color: var(--fg); - font-weight: 600; - font-size: 11.5px; - text-transform: uppercase; - letter-spacing: 0.03em; -} -.result-card-answer hr { - border: none; - border-top: 1px solid var(--border); - margin: 14px 0; -} - -.result-card-sources { - padding: 10px 16px 14px; - border-top: 1px solid var(--border); -} -.result-card-sources-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--fg-dim); - margin-bottom: 6px; -} -.source-link { - display: block; - padding: 4px 0; - font-size: 12.5px; - color: var(--fg-muted); - text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: color 0.12s; -} -.source-link:hover { color: var(--accent); } -.source-domain { - color: var(--fg-dim); - margin-left: 6px; -} - -.result-card-error-msg { - padding: 12px 16px; - font-size: 13px; - color: var(--timer-urgent-fg); -} - -.searching-dots::after { - content: ""; - animation: dots 1.5s steps(4, end) infinite; -} -@keyframes dots { - 0% { content: ""; } - 25% { content: "."; } - 50% { content: ".."; } - 75% { content: "..."; } -} - -.add-search { - display: flex; - align-items: center; - gap: 10px; - margin-top: 12px; - padding: 11px 14px; - border: 1px dashed var(--border); - border-radius: var(--radius); - cursor: text; - transition: border-color 0.15s, background 0.15s; -} -.add-search:hover { - border-color: var(--border-muted); - background: var(--accent-subtle); -} -.add-search:focus-within { - border-color: var(--accent); - border-style: solid; - background: var(--accent-subtle); -} -.add-search-icon { - color: var(--fg-dim); - font-size: 16px; - font-weight: 300; - line-height: 1; - flex-shrink: 0; - transition: color 0.15s; -} -.add-search:focus-within .add-search-icon { color: var(--accent); } -.add-search input { - flex: 1; - background: transparent; - border: none; - outline: none; - color: var(--fg); - font-family: var(--font); - font-size: 13.5px; - font-weight: 500; -} -.add-search input::placeholder { - color: var(--fg-dim); - font-weight: 400; -} -.add-search.loading { - opacity: 0.5; - pointer-events: none; -} - -.action-bar { - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 10; - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 24px; - background: color-mix(in srgb, var(--bg) 90%, transparent); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-top: 1px solid var(--border); -} -.action-shortcuts { display: flex; align-items: center; gap: 16px; } -.shortcut { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--fg-dim); } -.shortcut kbd { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 4px; - font-family: var(--font-mono); - font-size: 10px; - font-weight: 500; - background: var(--bg-elevated); - border: 1px solid var(--border-muted); - border-radius: 3px; - color: var(--fg-muted); -} -.action-buttons { display: flex; gap: 8px; } - -.btn { - font-family: var(--font); - font-size: 13px; - font-weight: 500; - padding: 7px 16px; - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - transition: background 0.12s, opacity 0.12s; -} -.btn:disabled { opacity: 0.35; cursor: default; } -.btn-submit { background: var(--btn-primary); color: var(--btn-primary-fg); } -.btn-submit:hover:not(:disabled) { background: var(--btn-primary-hover); } -.btn-secondary { background: var(--btn-secondary); color: var(--fg-muted); border: 1px solid var(--border); } -.btn-secondary:hover:not(:disabled) { background: var(--btn-secondary-hover); color: var(--fg); } - -.success-overlay { - position: fixed; inset: 0; z-index: 200; - background: var(--overlay-bg); - display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; - transition: opacity 200ms; -} -.success-overlay.hidden { display: flex !important; opacity: 0; pointer-events: none; } -.success-icon { - width: 56px; height: 56px; border-radius: 50%; - border: 2px solid var(--success); - display: flex; align-items: center; justify-content: center; - font-size: 18px; font-weight: 700; color: var(--success); -} -.success-overlay p { margin: 0; font-size: 13px; font-weight: 600; color: var(--success); letter-spacing: 0.06em; text-transform: uppercase; } - -.expired-overlay { - position: fixed; inset: 0; - background: var(--overlay-bg); - display: flex; align-items: center; justify-content: center; - opacity: 0; transition: opacity 400ms; pointer-events: none; z-index: 200; -} -.expired-overlay.visible { opacity: 1; pointer-events: auto; } -.expired-overlay.hidden { display: flex !important; opacity: 0; pointer-events: none; } -.expired-content { - text-align: center; max-width: 480px; padding: 48px 56px; - background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; -} -.expired-overlay.visible .expired-content { animation: slide-up 400ms ease-out; } -@keyframes slide-up { from { transform: translateY(20px); } to { transform: translateY(0); } } -.expired-icon { - width: 72px; height: 72px; border-radius: 50%; border: 2px solid var(--warning); - display: flex; align-items: center; justify-content: center; - font-size: 32px; font-weight: bold; color: var(--warning); margin: 0 auto 24px; -} -.expired-content h2 { color: var(--fg); margin: 0 0 16px; font-size: 22px; font-weight: 600; } -.expired-content p { color: var(--fg-muted); margin: 0 0 24px; font-size: 14px; line-height: 1.6; } -.expired-countdown { font-size: 13px; color: var(--fg-dim); font-variant-numeric: tabular-nums; } -.expired-countdown span { color: var(--warning); font-weight: 600; } - -.error-banner { - position: fixed; bottom: 64px; left: 50%; transform: translateX(-50%); z-index: 50; - padding: 10px 20px; background: var(--timer-urgent-bg); color: var(--timer-urgent-fg); - border-radius: var(--radius); font-size: 13px; font-weight: 500; -} - -@media (max-width: 500px) { - main { padding: 32px 16px 16px; } - .hero-title { font-size: 28px; } - .hero-desc { font-size: 13px; } - .action-bar { padding: 10px 14px; } - .action-shortcuts { display: none; } - .result-card-header { padding: 12px 14px; } - .expired-content { padding: 32px 24px; } - .timer-badge { top: 12px; right: 16px; } -} -`; - -const SCRIPT = `(function() { - var DATA = __INLINE_DATA__; - var token = DATA.sessionToken; - var timeoutSec = DATA.timeout; - var queries = DATA.queries; - var submitted = false; - var timerExpired = false; - var searchesDone = false; - var lastInteraction = Date.now(); - var completedCount = 0; - var es = null; - - var timerEl = document.getElementById("timer"); - var timerAdjustEl = document.getElementById("timer-adjust"); - var timerInput = document.getElementById("timer-input"); - var timerSetBtn = document.getElementById("timer-set"); - var heroTitle = document.querySelector(".hero-title"); - var heroDesc = document.querySelector(".hero-desc"); - var resultCardsEl = document.getElementById("result-cards"); - var btnSendAll = document.getElementById("btn-send-all"); - var btnSend = document.getElementById("btn-send"); - var successOverlay = document.getElementById("success-overlay"); - var successText = document.getElementById("success-text"); - var expiredOverlay = document.getElementById("expired-overlay"); - var expiredText = document.getElementById("expired-text"); - var closeCountdown = document.getElementById("close-countdown"); - var errorBanner = document.getElementById("error-banner"); - var addSearchInput = document.getElementById("add-search-input"); - var addSearchEl = document.getElementById("add-search"); - var heroStatus = document.getElementById("hero-status"); - var globalProvider = document.getElementById("global-provider"); - - function escHtml(s) { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - } - - function post(path, body) { - return fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(Object.assign({ token: token }, body)), - }); - } - - function formatTime(sec) { - var m = Math.floor(sec / 60); - var s = sec % 60; - return m + ":" + (s < 10 ? "0" : "") + s; - } - - function populateResultCard(card, data, queryText) { - var sourceCount = data.results ? data.results.length : 0; - var domains = []; - if (data.results) { - for (var i = 0; i < Math.min(data.results.length, 3); i++) { - domains.push(data.results[i].domain); - } - } - var metaText = sourceCount + " source" + (sourceCount !== 1 ? "s" : ""); - if (domains.length > 0) metaText += " \\u00B7 " + domains.join(", "); - if (sourceCount > 3) metaText += ", +" + (sourceCount - 3); - - var preview = ""; - if (data.answer) { - preview = data.answer.substring(0, 200).replace(/\\n+/g, " ").replace(/[#*_\\[\\]]/g, ""); - } - - var bodyHtml = ""; - if (data.answer) { - var rendered = typeof marked !== "undefined" && marked.parse - ? marked.parse(data.answer, { breaks: true }) - : '

' + escHtml(data.answer) + '

'; - bodyHtml += '
' + rendered + '
'; - } - if (data.results && data.results.length > 0) { - bodyHtml += '
Sources
'; - for (var k = 0; k < data.results.length; k++) { - var r = data.results[k]; - var label = r.title && r.title.indexOf("Source ") !== 0 ? r.title : r.url; - bodyHtml += '' + escHtml(label) + '' + escHtml(r.domain) + ''; - } - bodyHtml += '
'; - } - - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(queryText) + '
' + - '
' + escHtml(metaText) + '
' + - (preview ? '
' + escHtml(preview) + '
' : '') + - '
' + - '
\\u25BC
' + - '
' + - '
' + bodyHtml + '
'; - } - - // ── Timer ── - - function resetTimer() { lastInteraction = Date.now(); } - - function updateTimer() { - var idleSec = Math.floor((Date.now() - lastInteraction) / 1000); - var remaining = Math.max(0, timeoutSec - idleSec); - timerEl.textContent = formatTime(remaining); - - timerEl.classList.remove("warn", "urgent", "active"); - if (remaining <= 15) timerEl.classList.add("urgent"); - else if (remaining <= 30) timerEl.classList.add("warn"); - else if (remaining < timeoutSec) timerEl.classList.add("active"); - - if (remaining <= 0 && !submitted && !timerExpired) onTimeout(); - } - - setInterval(updateTimer, 1000); - updateTimer(); - - ["click", "keydown", "input", "change"].forEach(function(evt) { - document.addEventListener(evt, resetTimer, { passive: true }); - }); - document.addEventListener("scroll", resetTimer, { passive: true }); - document.addEventListener("mousemove", resetTimer, { passive: true }); - - // ── Timer adjust ── - - timerEl.addEventListener("click", function(e) { - e.stopPropagation(); - timerInput.value = timeoutSec; - timerAdjustEl.classList.add("visible"); - timerEl.style.display = "none"; - timerInput.focus(); - timerInput.select(); - }); - - function applyTimerAdjust() { - var val = parseInt(timerInput.value, 10); - if (val && val > 0) timeoutSec = Math.min(val, 600); - timerAdjustEl.classList.remove("visible"); - timerEl.style.display = ""; - resetTimer(); - } - - timerSetBtn.addEventListener("click", function(e) { e.stopPropagation(); applyTimerAdjust(); }); - timerInput.addEventListener("keydown", function(e) { - if (e.key === "Enter") { e.preventDefault(); applyTimerAdjust(); } - if (e.key === "Escape") { timerAdjustEl.classList.remove("visible"); timerEl.style.display = ""; } - e.stopPropagation(); - }); - document.addEventListener("click", function() { - if (timerAdjustEl.classList.contains("visible")) { - timerAdjustEl.classList.remove("visible"); - timerEl.style.display = ""; - } - }); - - // ── Provider ── - - if (globalProvider) { - globalProvider.addEventListener("change", function() { - post("/provider", { provider: globalProvider.value }); - }); - } - - // ── Add search ── - - addSearchInput.addEventListener("keydown", function(e) { - if (e.key !== "Enter") return; - var text = addSearchInput.value.trim(); - if (!text || submitted) return; - e.preventDefault(); - e.stopPropagation(); - - addSearchEl.classList.add("loading"); - addSearchInput.value = ""; - - var card = document.createElement("div"); - card.className = "result-card searching"; - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(text) + '
' + - '
Searching
' + - '
' + - '
'; - resultCardsEl.appendChild(card); - resetTimer(); - - post("/search", { query: text }).then(function(res) { - return res.json(); - }).then(function(data) { - addSearchEl.classList.remove("loading"); - if (!data.ok) { card.remove(); return; } - - card.dataset.qi = data.queryIndex; - - if (data.error) { - card.classList.remove("searching"); - card.classList.add("error"); - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(text) + '
' + - '
Failed
' + - '
' + - '
' + - '
' + escHtml(data.error) + '
'; - return; - } - - card.classList.remove("searching"); - card.classList.add("checked"); - completedCount++; - - populateResultCard(card, data, text); - setupCardInteraction(card); - updateSendButton(); - heroTitle.textContent = completedCount + " Search" + (completedCount !== 1 ? "es" : "") + " Complete"; - heroDesc.textContent = "Review the results and send what you want back to your agent."; - if (heroStatus) heroStatus.textContent = completedCount + " completed"; - resetTimer(); - }).catch(function() { - addSearchEl.classList.remove("loading"); - card.remove(); - }); - }); - - // ── Overlays ── - - function showSuccess(text) { - if (es) { es.close(); es = null; } - successText.textContent = text; - successOverlay.classList.remove("hidden"); - setTimeout(function() { window.close(); }, 800); - } - - function showExpired(text) { - if (es) { es.close(); es = null; } - expiredText.textContent = text; - expiredOverlay.classList.remove("hidden"); - requestAnimationFrame(function() { expiredOverlay.classList.add("visible"); }); - } - - function showError(text) { - errorBanner.textContent = text; - errorBanner.hidden = false; - } - - function onTimeout() { - if (submitted || timerExpired) return; - timerExpired = true; - submitted = true; - showExpired("Time\\u2019s up \\u2014 sending all results to your agent."); - post("/cancel", { reason: "timeout" }); - var count = 5; - closeCountdown.textContent = count; - var iv = setInterval(function() { - count--; - closeCountdown.textContent = count; - if (count <= 0) { clearInterval(iv); window.close(); } - }, 1000); - } - - // ── Create placeholder cards for each query ── - - if (queries.length === 0) { - heroTitle.textContent = "What do you need?"; - heroDesc.textContent = "Search for anything below. Results get sent back to your agent."; - if (heroStatus) heroStatus.textContent = ""; - btnSend.textContent = "No results yet"; - } else { - for (var i = 0; i < queries.length; i++) { - var card = document.createElement("div"); - card.className = "result-card searching"; - card.dataset.qi = i; - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(queries[i]) + '
' + - '
Searching
' + - '
' + - '
'; - resultCardsEl.appendChild(card); - } - } - - // ── SSE ── - - es = new EventSource("/events?session=" + encodeURIComponent(token)); - - es.addEventListener("result", function(e) { - var data = JSON.parse(e.data); - var card = resultCardsEl.querySelector('.result-card[data-qi="' + data.queryIndex + '"]'); - if (!card) return; - - card.classList.remove("searching"); - card.classList.add("checked"); - completedCount++; - - populateResultCard(card, data, data.query || queries[data.queryIndex]); - setupCardInteraction(card); - updateSendButton(); - resetTimer(); - }); - - es.addEventListener("search-error", function(e) { - var data = JSON.parse(e.data); - var card = resultCardsEl.querySelector('.result-card[data-qi="' + data.queryIndex + '"]'); - if (!card) return; - - card.classList.remove("searching"); - card.classList.add("error"); - completedCount++; - - card.innerHTML = - '
' + - '' + - '
' + - '
' + escHtml(data.query || queries[data.queryIndex]) + '
' + - '
Failed
' + - '
' + - '
' + - '
' + escHtml(data.error || "Search failed") + '
'; - - updateSendButton(); - resetTimer(); - }); - - es.addEventListener("done", function() { - searchesDone = true; - if (completedCount > 0) { - heroTitle.textContent = completedCount + " Search" + (completedCount !== 1 ? "es" : "") + " Complete"; - heroDesc.textContent = "Review the results and send what you want back to your agent."; - if (heroStatus) heroStatus.textContent = completedCount + " completed"; - } - updateSendButton(); - resetTimer(); - }); - - es.onerror = function() {}; - - // ── Card interaction ── - - function setupCardInteraction(card) { - var header = card.querySelector(".result-card-header"); - var body = card.querySelector(".result-card-body"); - var cb = card.querySelector("input[type=checkbox]"); - var expandEl = card.querySelector(".result-card-expand"); - - header.addEventListener("click", function(e) { - if (e.target.tagName === "A") return; - if (e.target === cb) { - card.classList.toggle("checked", cb.checked); - updateSendButton(); - return; - } - var isExpanded = body && body.classList.contains("open"); - if (body) body.classList.toggle("open"); - if (expandEl) expandEl.textContent = isExpanded ? "\\u25BC" : "\\u25B2"; - }); - - if (body) { - body.addEventListener("click", function(e) { - e.stopPropagation(); - }); - } - } - - // ── Send button ── - - function getSelectedIndices() { - var indices = []; - var cards = resultCardsEl.querySelectorAll(".result-card"); - cards.forEach(function(card) { - var cb = card.querySelector("input[type=checkbox]"); - if (cb && cb.checked && !cb.disabled) { - indices.push(parseInt(card.dataset.qi, 10)); - } - }); - return indices; - } - - function updateSendButton() { - var sel = getSelectedIndices(); - var hasResults = completedCount > 0; - btnSend.disabled = !hasResults || sel.length === 0; - if (!hasResults) { - btnSend.textContent = searchesDone ? "No results yet" : "Waiting for results\\u2026"; - } else { - btnSend.textContent = "Send Selected (" + sel.length + ")"; - } - btnSendAll.hidden = !searchesDone || !hasResults; - } - - // ── Submit / Cancel ── - - function doSubmit(indices) { - if (submitted) return; - submitted = true; - post("/submit", { selected: indices }).then(function(res) { - if (!res.ok) throw new Error("submit failed"); - showSuccess("Results sent"); - }).catch(function() { - submitted = false; - showError("Failed to send \\u2014 the agent may have moved on"); - }); - } - - function doCancel() { - if (submitted) return; - submitted = true; - post("/cancel", { reason: "user" }).then(function(res) { - if (!res.ok) throw new Error("cancel failed"); - showSuccess("Skipped"); - }).catch(function() { - submitted = false; - showError("Failed \\u2014 the agent may have moved on"); - }); - } - - // ── Button handlers ── - - btnSend.addEventListener("click", function() { - var sel = getSelectedIndices(); - if (sel.length > 0) doSubmit(sel); - }); - - btnSendAll.addEventListener("click", function() { - var all = []; - resultCardsEl.querySelectorAll(".result-card").forEach(function(card) { - var cb = card.querySelector("input[type=checkbox]"); - if (cb && !cb.disabled) all.push(parseInt(card.dataset.qi, 10)); - }); - if (all.length > 0) doSubmit(all); - }); - - // ── Keyboard ── - - document.addEventListener("keydown", function(e) { - if (submitted || timerExpired) return; - if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT") return; - - if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - var sel = getSelectedIndices(); - if (sel.length > 0) doSubmit(sel); - } else if (e.key === "Escape") { - e.preventDefault(); - doCancel(); - } else if (e.key === "a" && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - var boxes = resultCardsEl.querySelectorAll("input[type=checkbox]:not(:disabled)"); - var allChecked = true; - boxes.forEach(function(cb) { if (!cb.checked) allChecked = false; }); - boxes.forEach(function(cb) { - cb.checked = !allChecked; - cb.closest(".result-card").classList.toggle("checked", cb.checked); - }); - updateSendButton(); - resetTimer(); - } - }); - - // ── Heartbeat ── - - setInterval(function() { - if (submitted) return; - post("/heartbeat", {}); - }, 10000); - - // ── Focus add-search input when no initial queries ── - - if (queries.length === 0 && addSearchInput) { - addSearchInput.focus(); - } -})();`; diff --git a/extensions/pi-web-access/curator-server.ts b/extensions/pi-web-access/curator-server.ts deleted file mode 100644 index 22badc7..0000000 --- a/extensions/pi-web-access/curator-server.ts +++ /dev/null @@ -1,325 +0,0 @@ -import http, { type IncomingMessage, type ServerResponse } from "node:http"; -import { generateCuratorPage } from "./curator-page.js"; - -const STALE_THRESHOLD_MS = 30000; -const WATCHDOG_INTERVAL_MS = 5000; -const MAX_BODY_SIZE = 64 * 1024; - -type ServerState = "SEARCHING" | "RESULT_SELECTION" | "COMPLETED"; - -export interface CuratorServerOptions { - queries: string[]; - sessionToken: string; - timeout: number; - availableProviders: { perplexity: boolean; gemini: boolean }; - defaultProvider: string; -} - -export interface CuratorServerCallbacks { - onSubmit: (selectedQueryIndices: number[]) => void; - onCancel: (reason: "user" | "timeout" | "stale") => void; - onProviderChange: (provider: string) => void; - onAddSearch: (query: string, queryIndex: number) => Promise<{ answer: string; results: Array<{ title: string; url: string; domain: string }> }>; -} - -export interface CuratorServerHandle { - server: http.Server; - url: string; - close: () => void; - pushResult: (queryIndex: number, data: { answer: string; results: Array<{ title: string; url: string; domain: string }> }) => void; - pushError: (queryIndex: number, error: string) => void; - searchesDone: () => void; -} - -function sendJson(res: ServerResponse, status: number, payload: unknown): void { - res.writeHead(status, { - "Content-Type": "application/json", - "Cache-Control": "no-store", - }); - res.end(JSON.stringify(payload)); -} - -function parseJSONBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ""; - let size = 0; - req.on("data", (chunk: Buffer) => { - size += chunk.length; - if (size > MAX_BODY_SIZE) { - req.destroy(); - reject(new Error("Request body too large")); - return; - } - body += chunk.toString(); - }); - req.on("end", () => { - try { resolve(JSON.parse(body)); } - catch { reject(new Error("Invalid JSON")); } - }); - req.on("error", reject); - }); -} - -export function startCuratorServer( - options: CuratorServerOptions, - callbacks: CuratorServerCallbacks, -): Promise { - const { queries, sessionToken, timeout, availableProviders, defaultProvider } = options; - let browserConnected = false; - let lastHeartbeatAt = Date.now(); - let completed = false; - let watchdog: NodeJS.Timeout | null = null; - let state: ServerState = "SEARCHING"; - let sseResponse: ServerResponse | null = null; - const sseBuffer: string[] = []; - let nextQueryIndex = queries.length; - - let sseKeepalive: NodeJS.Timeout | null = null; - - const markCompleted = (): boolean => { - if (completed) return false; - completed = true; - state = "COMPLETED"; - if (watchdog) { clearInterval(watchdog); watchdog = null; } - if (sseKeepalive) { clearInterval(sseKeepalive); sseKeepalive = null; } - if (sseResponse) { - try { sseResponse.end(); } catch {} - sseResponse = null; - } - return true; - }; - - const touchHeartbeat = (): void => { - lastHeartbeatAt = Date.now(); - browserConnected = true; - }; - - function validateToken(body: unknown, res: ServerResponse): boolean { - if (!body || typeof body !== "object") { - sendJson(res, 400, { ok: false, error: "Invalid body" }); - return false; - } - if ((body as { token?: string }).token !== sessionToken) { - sendJson(res, 403, { ok: false, error: "Invalid session" }); - return false; - } - return true; - } - - function sendSSE(event: string, data: unknown): void { - const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; - const res = sseResponse; - if (res && !res.writableEnded && res.socket && !res.socket.destroyed) { - try { - const ok = res.write(payload); - if (!ok) res.once("drain", () => {}); - } catch { - sseBuffer.push(payload); - } - } else { - sseBuffer.push(payload); - } - } - - const pageHtml = generateCuratorPage(queries, sessionToken, timeout, availableProviders, defaultProvider); - - const server = http.createServer(async (req, res) => { - try { - const method = req.method || "GET"; - const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`); - - if (method === "GET" && url.pathname === "/") { - const token = url.searchParams.get("session"); - if (token !== sessionToken) { - res.writeHead(403, { "Content-Type": "text/plain" }); - res.end("Invalid session"); - return; - } - touchHeartbeat(); - res.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store", - }); - res.end(pageHtml); - return; - } - - if (method === "GET" && url.pathname === "/events") { - const token = url.searchParams.get("session"); - if (token !== sessionToken) { - res.writeHead(403, { "Content-Type": "text/plain" }); - res.end("Invalid session"); - return; - } - if (state === "COMPLETED") { - sendJson(res, 409, { ok: false, error: "No events available" }); - return; - } - if (sseResponse) { - try { sseResponse.end(); } catch {} - } - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }); - res.flushHeaders(); - if (res.socket) res.socket.setNoDelay(true); - sseResponse = res; - for (const msg of sseBuffer) { - try { res.write(msg); } catch {} - } - sseBuffer.length = 0; - if (sseKeepalive) clearInterval(sseKeepalive); - sseKeepalive = setInterval(() => { - if (sseResponse) { - try { sseResponse.write(":keepalive\n\n"); } catch {} - } - }, 15000); - req.on("close", () => { - if (sseResponse === res) sseResponse = null; - }); - return; - } - - if (method === "POST" && url.pathname === "/heartbeat") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - touchHeartbeat(); - sendJson(res, 200, { ok: true }); - return; - } - - if (method === "POST" && url.pathname === "/provider") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - const { provider } = body as { provider?: string }; - if (typeof provider === "string" && provider.length > 0) { - setImmediate(() => callbacks.onProviderChange(provider)); - } - sendJson(res, 200, { ok: true }); - return; - } - - if (method === "POST" && url.pathname === "/search") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - if (state === "COMPLETED") { - sendJson(res, 409, { ok: false, error: "Session closed" }); - return; - } - const { query } = body as { query?: string }; - if (typeof query !== "string" || query.trim().length === 0) { - sendJson(res, 400, { ok: false, error: "Invalid query" }); - return; - } - const qi = nextQueryIndex++; - touchHeartbeat(); - try { - const result = await callbacks.onAddSearch(query.trim(), qi); - sendJson(res, 200, { ok: true, queryIndex: qi, answer: result.answer, results: result.results }); - } catch (err) { - const message = err instanceof Error ? err.message : "Search failed"; - sendJson(res, 200, { ok: true, queryIndex: qi, error: message }); - } - return; - } - - if (method === "POST" && url.pathname === "/submit") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - const { selected } = body as { selected?: number[] }; - if (!Array.isArray(selected) || !selected.every(n => typeof n === "number")) { - sendJson(res, 400, { ok: false, error: "Invalid selection" }); - return; - } - if (state !== "SEARCHING" && state !== "RESULT_SELECTION") { - sendJson(res, 409, { ok: false, error: "Cannot submit in current state" }); - return; - } - if (!markCompleted()) { - sendJson(res, 409, { ok: false, error: "Session closed" }); - return; - } - sendJson(res, 200, { ok: true }); - setImmediate(() => callbacks.onSubmit(selected)); - return; - } - - if (method === "POST" && url.pathname === "/cancel") { - const body = await parseJSONBody(req).catch(() => null); - if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; } - if (!validateToken(body, res)) return; - if (!markCompleted()) { - sendJson(res, 200, { ok: true }); - return; - } - const { reason } = body as { reason?: string }; - sendJson(res, 200, { ok: true }); - const cancelReason = reason === "timeout" ? "timeout" : "user"; - setImmediate(() => callbacks.onCancel(cancelReason)); - return; - } - - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not found"); - } catch (err) { - const message = err instanceof Error ? err.message : "Server error"; - sendJson(res, 500, { ok: false, error: message }); - } - }); - - return new Promise((resolve, reject) => { - const onError = (err: Error) => { - reject(new Error(`Curator server failed to start: ${err.message}`)); - }; - - server.once("error", onError); - server.listen(0, "127.0.0.1", () => { - server.off("error", onError); - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Curator server: invalid address")); - return; - } - const url = `http://localhost:${addr.port}/?session=${sessionToken}`; - - watchdog = setInterval(() => { - if (completed || !browserConnected) return; - if (Date.now() - lastHeartbeatAt <= STALE_THRESHOLD_MS) return; - if (!markCompleted()) return; - setImmediate(() => callbacks.onCancel("stale")); - }, WATCHDOG_INTERVAL_MS); - - resolve({ - server, - url, - close: () => { - const wasOpen = markCompleted(); - try { server.close(); } catch {} - if (wasOpen) { - setImmediate(() => callbacks.onCancel("stale")); - } - }, - pushResult: (queryIndex, data) => { - if (completed) return; - sendSSE("result", { queryIndex, query: queries[queryIndex] ?? "", ...data }); - }, - pushError: (queryIndex, error) => { - if (completed) return; - sendSSE("search-error", { queryIndex, query: queries[queryIndex] ?? "", error }); - }, - searchesDone: () => { - if (completed) return; - sendSSE("done", {}); - state = "RESULT_SELECTION"; - }, - }); - }); - }); -} diff --git a/extensions/pi-web-access/extract.ts b/extensions/pi-web-access/extract.ts deleted file mode 100644 index a859898..0000000 --- a/extensions/pi-web-access/extract.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { Readability } from "@mozilla/readability"; -import { parseHTML } from "linkedom"; -import TurndownService from "turndown"; -import pLimit from "p-limit"; -import { activityMonitor } from "./activity.js"; -import { extractRSCContent } from "./rsc-extract.js"; -import { extractPDFToMarkdown, isPDF } from "./pdf-extract.js"; -import { extractGitHub } from "./github-extract.js"; -import { isYouTubeURL, isYouTubeEnabled, extractYouTube, extractYouTubeFrame, extractYouTubeFrames, getYouTubeStreamInfo } from "./youtube-extract.js"; -import { extractWithUrlContext, extractWithGeminiWeb } from "./gemini-url-context.js"; -import { isVideoFile, extractVideo, extractVideoFrame, getLocalVideoDuration } from "./video-extract.js"; -import { formatSeconds } from "./utils.js"; - -const DEFAULT_TIMEOUT_MS = 30000; -const CONCURRENT_LIMIT = 3; - -const NON_RECOVERABLE_ERRORS = ["Unsupported content type", "Response too large"]; -const MIN_USEFUL_CONTENT = 500; - -const turndown = new TurndownService({ - headingStyle: "atx", - codeBlockStyle: "fenced", -}); - -const fetchLimit = pLimit(CONCURRENT_LIMIT); - -export interface VideoFrame { - data: string; - mimeType: string; - timestamp: string; -} - -export type FrameData = { data: string; mimeType: string }; -export type FrameResult = FrameData | { error: string }; - -export interface ExtractedContent { - url: string; - title: string; - content: string; - error: string | null; - thumbnail?: { data: string; mimeType: string }; - frames?: VideoFrame[]; - duration?: number; -} - -export interface ExtractOptions { - timeoutMs?: number; - forceClone?: boolean; - prompt?: string; - timestamp?: string; - frames?: number; - model?: string; -} - -const JINA_READER_BASE = "https://r.jina.ai/"; -const JINA_TIMEOUT_MS = 30000; - -async function extractWithJinaReader( - url: string, - signal?: AbortSignal, -): Promise { - const jinaUrl = JINA_READER_BASE + url; - - const activityId = activityMonitor.logStart({ type: "api", query: `jina: ${url}` }); - - try { - const res = await fetch(jinaUrl, { - headers: { - "Accept": "text/markdown", - "X-No-Cache": "true", - }, - signal: AbortSignal.any([ - AbortSignal.timeout(JINA_TIMEOUT_MS), - ...(signal ? [signal] : []), - ]), - }); - - if (!res.ok) { - activityMonitor.logComplete(activityId, res.status); - return null; - } - - const content = await res.text(); - activityMonitor.logComplete(activityId, res.status); - - const contentStart = content.indexOf("Markdown Content:"); - if (contentStart < 0) { - return null; - } - - const markdownPart = content.slice(contentStart + 17).trim(); // 17 = "Markdown Content:".length - - // Check for failed JS rendering or minimal content - if (markdownPart.length < 100 || - markdownPart.startsWith("Loading...") || - markdownPart.startsWith("Please enable JavaScript")) { - return null; - } - - const title = extractHeadingTitle(markdownPart) ?? (new URL(url).pathname.split("/").pop() || url); - return { url, title, content: markdownPart, error: null }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.toLowerCase().includes("abort")) { - activityMonitor.logComplete(activityId, 0); - } else { - activityMonitor.logError(activityId, message); - } - return null; - } -} - -function parseTimestamp(ts: string): number | null { - const num = Number(ts); - if (!isNaN(num) && num >= 0) return Math.floor(num); - const parts = ts.split(":").map(Number); - if (parts.some(p => isNaN(p) || p < 0)) return null; - if (parts.length === 3) return Math.floor(parts[0] * 3600 + parts[1] * 60 + parts[2]); - if (parts.length === 2) return Math.floor(parts[0] * 60 + parts[1]); - return null; -} - -type TimestampSpec = { type: "single"; seconds: number } | { type: "range"; start: number; end: number }; - -function parseTimestampSpec(ts: string): TimestampSpec | null { - const dashIdx = ts.indexOf("-", 1); - if (dashIdx > 0) { - const start = parseTimestamp(ts.slice(0, dashIdx)); - const end = parseTimestamp(ts.slice(dashIdx + 1)); - if (start !== null && end !== null && end > start) return { type: "range", start, end }; - } - const seconds = parseTimestamp(ts); - return seconds !== null ? { type: "single", seconds } : null; -} - -const DEFAULT_RANGE_FRAMES = 6; -const MIN_FRAME_INTERVAL = 5; - -function computeRangeTimestamps(start: number, end: number, maxFrames: number = DEFAULT_RANGE_FRAMES): number[] { - if (maxFrames <= 1) return [start]; - const duration = end - start; - const idealInterval = duration / (maxFrames - 1); - if (idealInterval < MIN_FRAME_INTERVAL) { - const timestamps: number[] = []; - for (let t = start; t <= end && timestamps.length < maxFrames; t += MIN_FRAME_INTERVAL) { - timestamps.push(t); - } - return timestamps; - } - return Array.from({ length: maxFrames }, (_, i) => Math.round(start + i * idealInterval)); -} - -function buildFrameResult( - url: string, label: string, requestedCount: number, - frames: VideoFrame[], error: string | null, duration?: number, -): ExtractedContent { - if (frames.length === 0) { - const msg = error ?? "Frame extraction failed"; - return { url, title: `Frames ${label} (0/${requestedCount})`, content: msg, error: msg }; - } - return { - url, - title: `Frames ${label} (${frames.length}/${requestedCount})`, - content: `${frames.length} frames extracted from ${label}`, - error: null, - frames, - duration, - }; -} - -async function extractLocalFrames( - filePath: string, timestamps: number[], -): Promise<{ frames: VideoFrame[]; error: string | null }> { - const results = await Promise.all(timestamps.map(async (t) => { - const frame = await extractVideoFrame(filePath, t); - if ("error" in frame) return { error: frame.error }; - return { ...frame, timestamp: formatSeconds(t) }; - })); - const frames = results.filter((f): f is VideoFrame => "data" in f); - const firstError = results.find((f): f is { error: string } => "error" in f); - return { frames, error: frames.length === 0 && firstError ? firstError.error : null }; -} - -export async function extractContent( - url: string, - signal?: AbortSignal, - options?: ExtractOptions, -): Promise { - if (signal?.aborted) { - return { url, title: "", content: "", error: "Aborted" }; - } - - if (options?.frames && !options.timestamp) { - const frameCount = options.frames; - const ytInfo = isYouTubeURL(url); - if (ytInfo.isYouTube && ytInfo.videoId) { - const streamInfo = await getYouTubeStreamInfo(ytInfo.videoId); - if ("error" in streamInfo) { - return { url, title: "Frames", content: streamInfo.error, error: streamInfo.error }; - } - if (streamInfo.duration === null) { - const error = "Cannot determine video duration. Use a timestamp range instead."; - return { url, title: "Frames", content: error, error }; - } - const dur = Math.floor(streamInfo.duration); - const timestamps = computeRangeTimestamps(0, dur, frameCount); - const result = await extractYouTubeFrames(ytInfo.videoId, timestamps, streamInfo); - const label = `${formatSeconds(0)}-${formatSeconds(dur)}`; - return buildFrameResult(url, label, timestamps.length, result.frames, result.error, streamInfo.duration); - } - - const videoInfo = isVideoFile(url); - if (videoInfo) { - const durationResult = await getLocalVideoDuration(videoInfo.absolutePath); - if (typeof durationResult !== "number") { - return { url, title: "Frames", content: durationResult.error, error: durationResult.error }; - } - const dur = Math.floor(durationResult); - const timestamps = computeRangeTimestamps(0, dur, frameCount); - const result = await extractLocalFrames(videoInfo.absolutePath, timestamps); - const label = `${formatSeconds(0)}-${formatSeconds(dur)}`; - return buildFrameResult(url, label, timestamps.length, result.frames, result.error, durationResult); - } - - return { url, title: "", content: "", error: "Frame extraction only works with YouTube and local video files" }; - } - - if (options?.timestamp) { - const spec = parseTimestampSpec(options.timestamp); - if (spec) { - const frameCount = options.frames; - const ytInfo = isYouTubeURL(url); - if (ytInfo.isYouTube && ytInfo.videoId) { - const streamInfo = await getYouTubeStreamInfo(ytInfo.videoId); - if ("error" in streamInfo) { - if (spec.type === "range") { - const label = `${formatSeconds(spec.start)}-${formatSeconds(spec.end)}`; - return { url, title: `Frames ${label}`, content: streamInfo.error, error: streamInfo.error }; - } - if (frameCount) { - const end = spec.seconds + (frameCount - 1) * MIN_FRAME_INTERVAL; - const label = `${formatSeconds(spec.seconds)}-${formatSeconds(end)}`; - return { url, title: `Frames ${label}`, content: streamInfo.error, error: streamInfo.error }; - } - return { url, title: `Frame at ${options.timestamp}`, content: streamInfo.error, error: streamInfo.error }; - } - - if (spec.type === "range") { - const label = `${formatSeconds(spec.start)}-${formatSeconds(spec.end)}`; - if (streamInfo.duration !== null && spec.end > streamInfo.duration) { - const error = `Timestamp ${formatSeconds(spec.end)} exceeds video duration (${formatSeconds(Math.floor(streamInfo.duration))})`; - return { url, title: `Frames ${label}`, content: error, error }; - } - const timestamps = frameCount - ? computeRangeTimestamps(spec.start, spec.end, frameCount) - : computeRangeTimestamps(spec.start, spec.end); - const result = await extractYouTubeFrames(ytInfo.videoId, timestamps, streamInfo); - return buildFrameResult(url, label, timestamps.length, result.frames, result.error, result.duration ?? undefined); - } - - if (frameCount) { - const end = spec.seconds + (frameCount - 1) * MIN_FRAME_INTERVAL; - const label = `${formatSeconds(spec.seconds)}-${formatSeconds(end)}`; - if (streamInfo.duration !== null && end > streamInfo.duration) { - const error = `Timestamp ${formatSeconds(end)} exceeds video duration (${formatSeconds(Math.floor(streamInfo.duration))})`; - return { url, title: `Frames ${label}`, content: error, error }; - } - const timestamps = computeRangeTimestamps(spec.seconds, end, frameCount); - const result = await extractYouTubeFrames(ytInfo.videoId, timestamps, streamInfo); - return buildFrameResult(url, label, timestamps.length, result.frames, result.error, result.duration ?? undefined); - } - - if (streamInfo.duration !== null && spec.seconds > streamInfo.duration) { - const error = `Timestamp ${formatSeconds(spec.seconds)} exceeds video duration (${formatSeconds(Math.floor(streamInfo.duration))})`; - return { url, title: `Frame at ${options.timestamp}`, content: error, error }; - } - const frame = await extractYouTubeFrame(ytInfo.videoId, spec.seconds, streamInfo); - if ("error" in frame) { - return { url, title: `Frame at ${options.timestamp}`, content: frame.error, error: frame.error }; - } - return { url, title: `Frame at ${options.timestamp}`, content: `Video frame at ${options.timestamp}`, error: null, thumbnail: frame }; - } - - const videoInfo = isVideoFile(url); - if (videoInfo) { - if (spec.type === "range") { - const timestamps = frameCount - ? computeRangeTimestamps(spec.start, spec.end, frameCount) - : computeRangeTimestamps(spec.start, spec.end); - const result = await extractLocalFrames(videoInfo.absolutePath, timestamps); - const label = `${formatSeconds(spec.start)}-${formatSeconds(spec.end)}`; - return buildFrameResult(url, label, timestamps.length, result.frames, result.error); - } - - if (frameCount) { - const end = spec.seconds + (frameCount - 1) * MIN_FRAME_INTERVAL; - const timestamps = computeRangeTimestamps(spec.seconds, end, frameCount); - const result = await extractLocalFrames(videoInfo.absolutePath, timestamps); - const label = `${formatSeconds(spec.seconds)}-${formatSeconds(end)}`; - return buildFrameResult(url, label, timestamps.length, result.frames, result.error); - } - - const frame = await extractVideoFrame(videoInfo.absolutePath, spec.seconds); - if ("error" in frame) { - return { url, title: `Frame at ${options.timestamp}`, content: frame.error, error: frame.error }; - } - return { url, title: `Frame at ${options.timestamp}`, content: `Video frame at ${options.timestamp}`, error: null, thumbnail: frame }; - } - } - } - - const videoInfo = isVideoFile(url); - if (videoInfo) { - const result = await extractVideo(videoInfo, signal, options); - return result ?? { url, title: "", content: "", error: "Video analysis requires Gemini access. Either:\n 1. Sign into gemini.google.com in Chrome (free, uses cookies)\n 2. Set GEMINI_API_KEY in ~/.pi/web-search.json" }; - } - - try { - new URL(url); - } catch { - return { url, title: "", content: "", error: "Invalid URL" }; - } - - try { - const ghResult = await extractGitHub(url, signal, options?.forceClone); - if (ghResult) return ghResult; - } catch {} - - const ytInfo = isYouTubeURL(url); - if (ytInfo.isYouTube && isYouTubeEnabled()) { - try { - const ytResult = await extractYouTube(url, signal, options?.prompt, options?.model); - if (ytResult) return ytResult; - } catch {} - return { - url, - title: "", - content: "", - error: "Could not extract YouTube video content. Sign into Google in Chrome for automatic access, or set GEMINI_API_KEY.", - }; - } - - const httpResult = await extractViaHttp(url, signal, options); - - if (!httpResult.error || signal?.aborted) return httpResult; - if (NON_RECOVERABLE_ERRORS.some(prefix => httpResult.error!.startsWith(prefix))) return httpResult; - - const jinaResult = await extractWithJinaReader(url, signal); - if (jinaResult) return jinaResult; - - const geminiResult = await extractWithUrlContext(url, signal) - ?? await extractWithGeminiWeb(url, signal); - - if (geminiResult) return geminiResult; - - const guidance = [ - httpResult.error, - "", - "Fallback options:", - " \u2022 Set GEMINI_API_KEY in ~/.pi/web-search.json", - " \u2022 Sign into gemini.google.com in Chrome", - " \u2022 Use web_search to find content about this topic", - ].join("\n"); - return { ...httpResult, error: guidance }; -} - -function isLikelyJSRendered(html: string): boolean { - // Extract body content - const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); - if (!bodyMatch) return false; - - const bodyHtml = bodyMatch[1]; - - // Strip tags to get text content - const textContent = bodyHtml - .replace(//gi, "") - .replace(//gi, "") - .replace(/<[^>]+>/g, "") - .replace(/\s+/g, " ") - .trim(); - - // Count scripts - const scriptCount = (html.match(/ tags. - */ - -export interface RSCExtractResult { - title: string; - content: string; -} - -export function extractRSCContent(html: string): RSCExtractResult | null { - if (!html.includes("self.__next_f.push")) { - return null; - } - - // Parse all RSC chunks into a map - const chunkMap = new Map(); - const scriptRegex = / + + diff --git a/extensions/piff/server.ts b/extensions/piff/server.ts new file mode 100644 index 0000000..66657b1 --- /dev/null +++ b/extensions/piff/server.ts @@ -0,0 +1,182 @@ +import { createServer, type ServerResponse } from "node:http"; +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getRepoName, getCurrentBranch } from "./git.js"; +import { DiffSession } from "./diff-session.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadPageHTML(): string { + // Try built Svelte app first, fall back to legacy page.html + const distPath = join(__dirname, "dist", "index.html"); + if (existsSync(distPath)) return readFileSync(distPath, "utf-8"); + return readFileSync(join(__dirname, "page.html"), "utf-8"); +} + +const PAGE_HTML = loadPageHTML(); + +export interface ServerOptions { + port: number; + diffArgs: string[]; + description: string; + cwd: string; +} + +let activeServer: ReturnType | null = null; +let activeSession: DiffSession | null = null; + +export function stopServer() { + activeSession?.destroy(); + activeSession = null; + if (activeServer) { + try { + activeServer.close(); + } catch {} + activeServer = null; + } +} + +export function startServer(opts: ServerOptions): Promise { + const { port, diffArgs, description, cwd } = opts; + stopServer(); + + const session = new DiffSession(diffArgs, cwd); + activeSession = session; + session.preloadAll().catch(() => {}); + + const json = (res: ServerResponse, data: unknown) => { + res.writeHead(200, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(JSON.stringify(data)); + }; + + const error = (res: ServerResponse, code: number, msg: string) => { + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: msg })); + }; + + return new Promise((resolve, reject) => { + const s = createServer(async (req, res) => { + try { + const p = new URL(req.url || "/", "http://x").pathname; + + if (req.method === "OPTIONS") { + res.writeHead(204, { "Access-Control-Allow-Origin": "*" }); + res.end(); + return; + } + + if (p === "/api/info") { + const files = session.getFiles(); + json(res, { + name: getRepoName(cwd), + branch: getCurrentBranch(cwd), + description, + fileCount: files.length, + cached: session.cachedCount, + total: files.length, + }); + return; + } + + if (p === "/api/files") { + json(res, session.getFiles()); + return; + } + + if (p === "/api/options") { + if (req.method === "GET") { + json(res, session.renderOptions); + return; + } + let body = ""; + req.on("data", (c: Buffer) => { + body += c; + }); + req.on("end", () => { + try { + const opts = JSON.parse(body); + session.setOptions(opts); + json(res, session.renderOptions); + } catch { + error(res, 400, "Invalid JSON"); + } + }); + return; + } + + if (p === "/api/refresh") { + session.refresh(); + json(res, { ok: true }); + return; + } + + if (p === "/api/preload-status") { + json(res, session.preloadStatus()); + return; + } + + if (p === "/api/preload-stream") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + + const send = () => { + const status = session.preloadStatus(); + res.write(`data: ${JSON.stringify(status)}\n\n`); + if (!status.preloading) { + clearInterval(timer); + res.end(); + } + }; + + send(); + const timer = setInterval(send, 300); + + req.on("close", () => clearInterval(timer)); + return; + } + + if (p.startsWith("/api/diff/")) { + const filePath = decodeURIComponent(p.slice(10)); + const file = session.getFiles().find((f) => f.path === filePath); + if (!file) { + error(res, 404, "Not found"); + return; + } + try { + const html = await session.getDiffHTMLForUser(file); + json(res, { file, html }); + } catch (e) { + error(res, 500, String(e)); + } + return; + } + + // Serve the SPA + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + }); + res.end(PAGE_HTML); + } catch (e) { + error(res, 500, String(e)); + } + }); + + activeServer = s; + s.on("error", (e: NodeJS.ErrnoException) => + reject( + e.code === "EADDRINUSE" + ? new Error("Port " + port + " in use") + : e, + ), + ); + s.listen(port, "127.0.0.1", () => resolve(port)); + }); +} diff --git a/extensions/piff/src/App.svelte b/extensions/piff/src/App.svelte new file mode 100644 index 0000000..3c67172 --- /dev/null +++ b/extensions/piff/src/App.svelte @@ -0,0 +1,279 @@ + + + + +
+ +
+ + +
+
+ + + + + diff --git a/extensions/piff/src/components/DiffView.svelte b/extensions/piff/src/components/DiffView.svelte new file mode 100644 index 0000000..80e0d66 --- /dev/null +++ b/extensions/piff/src/components/DiffView.svelte @@ -0,0 +1,336 @@ + + +
+
+
+ {hunkBadgeText} +
+ + {#if loading} +
+ Loading… +
+ {:else if errorMsg} +
{errorMsg}
+ {:else if !currentHtml && !$selectedPath} +
Select a file
+ {/if} + +
+
+ + diff --git a/extensions/piff/src/components/HelpOverlay.svelte b/extensions/piff/src/components/HelpOverlay.svelte new file mode 100644 index 0000000..b1b2d86 --- /dev/null +++ b/extensions/piff/src/components/HelpOverlay.svelte @@ -0,0 +1,120 @@ + + + + +
+
+

Keyboard Shortcuts

+ +
+

Files

+
j / ↓Next file
+
k / ↑Previous file
+
JLast file
+
KFirst file
+
yCopy file path
+
bToggle sidebar
+
h / lCollapse / expand folder
+
H / LCollapse / expand all
+
+ +
+

Diff navigation

+
nNext changed hunk
+
NPrevious changed hunk
+
ggScroll to top
+
GScroll to bottom
+
Ctrl+d / uScroll ½ page down / up
+
Ctrl+f / bScroll full page down / up
+
+ +
+

Display

+
sToggle split / unified
+
wToggle ignore whitespace
+
eToggle expand unchanged
+
+ +
+

General

+
?Toggle this help
+
+ +
Press ? or Esc to close
+
+
+ + diff --git a/extensions/piff/src/components/Sidebar.svelte b/extensions/piff/src/components/Sidebar.svelte new file mode 100644 index 0000000..fa7ae53 --- /dev/null +++ b/extensions/piff/src/components/Sidebar.svelte @@ -0,0 +1,292 @@ + + + + + diff --git a/extensions/piff/src/components/Toast.svelte b/extensions/piff/src/components/Toast.svelte new file mode 100644 index 0000000..d6bb414 --- /dev/null +++ b/extensions/piff/src/components/Toast.svelte @@ -0,0 +1,45 @@ + + +
+ {$toastMessage ?? ""} +
+ + diff --git a/extensions/piff/src/components/Toolbar.svelte b/extensions/piff/src/components/Toolbar.svelte new file mode 100644 index 0000000..81b3bd0 --- /dev/null +++ b/extensions/piff/src/components/Toolbar.svelte @@ -0,0 +1,194 @@ + + +
+ {$repoInfo?.name ?? ""} + {$repoInfo?.branch ?? ""} + {$repoInfo?.description ?? ""} + + {$files.length} file{$files.length !== 1 ? "s" : ""} + {#if $totalAdditions > 0} + +{$totalAdditions} + {/if} + {#if $totalDeletions > 0} + −{$totalDeletions} + {/if} + + {#if $preloadStatus.total > 0} + + {#if $preloadStatus.preloading} + + {/if} + {$preloadStatus.cached}/{$preloadStatus.total} + + {/if} + + + + + + + +
+ + diff --git a/extensions/piff/src/index.html b/extensions/piff/src/index.html new file mode 100644 index 0000000..d0383af --- /dev/null +++ b/extensions/piff/src/index.html @@ -0,0 +1,12 @@ + + + + + + piff + + +
+ + + diff --git a/extensions/piff/src/lib/api.ts b/extensions/piff/src/lib/api.ts new file mode 100644 index 0000000..e0511ef --- /dev/null +++ b/extensions/piff/src/lib/api.ts @@ -0,0 +1,45 @@ +import type { ChangedFile, RepoInfo, RenderOptions } from "./state"; + +export async function fetchInfo(): Promise { + const r = await fetch("/api/info"); + return r.json(); +} + +export async function fetchFiles(): Promise { + const r = await fetch("/api/files"); + return r.json(); +} + +export async function fetchDiff( + path: string, + signal?: AbortSignal +): Promise<{ file: ChangedFile; html: string } | null> { + const r = await fetch("/api/diff/" + encodeURIComponent(path), { signal }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error((d as any).error || "HTTP " + r.status); + } + return r.json(); +} + +export async function fetchOptions(): Promise { + const r = await fetch("/api/options"); + return r.json(); +} + +export async function updateOptions( + opts: Partial +): Promise { + const r = await fetch("/api/options", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts), + }); + return r.json(); +} + +export async function refresh(): Promise { + await fetch("/api/refresh"); +} + + diff --git a/extensions/piff/src/lib/keyboard.ts b/extensions/piff/src/lib/keyboard.ts new file mode 100644 index 0000000..3ff37ca --- /dev/null +++ b/extensions/piff/src/lib/keyboard.ts @@ -0,0 +1,94 @@ +/** + * Keyboard handler – dispatches actions based on key presses. + * Returns an action descriptor so the caller (App.svelte) can handle side effects. + */ + +export type Action = + | { type: "nextFile" } + | { type: "prevFile" } + | { type: "firstFile" } + | { type: "lastFile" } + | { type: "copyPath" } + | { type: "toggleSidebar" } + | { type: "collapseFolder" } + | { type: "expandFolder" } + | { type: "collapseAll" } + | { type: "expandAll" } + | { type: "nextHunk" } + | { type: "prevHunk" } + | { type: "scrollTop" } + | { type: "scrollBottom" } + | { type: "scrollHalfDown" } + | { type: "scrollHalfUp" } + | { type: "scrollPageDown" } + | { type: "scrollPageUp" } + | { type: "toggleStyle" } + | { type: "toggleWhitespace" } + | { type: "toggleExpand" } + | { type: "toggleHelp" } + | null; + +let pendingG = false; + +export function handleKeydown(e: KeyboardEvent): Action { + if ( + (e.target as HTMLElement).tagName === "INPUT" || + (e.target as HTMLElement).tagName === "TEXTAREA" + ) + return null; + + if (e.key === "?") { + e.preventDefault(); + return { type: "toggleHelp" }; + } + + if (e.key === "Escape") { + e.preventDefault(); + return { type: "toggleHelp" }; // App will check if help is visible + } + + // Ctrl combos + if (e.ctrlKey) { + switch (e.key) { + case "d": e.preventDefault(); return { type: "scrollHalfDown" }; + case "u": e.preventDefault(); return { type: "scrollHalfUp" }; + case "f": e.preventDefault(); return { type: "scrollPageDown" }; + case "b": e.preventDefault(); return { type: "scrollPageUp" }; + } + return null; + } + + // gg sequence + if (e.key === "g" && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + if (pendingG) { + e.preventDefault(); + pendingG = false; + return { type: "scrollTop" }; + } + pendingG = true; + setTimeout(() => { pendingG = false; }, 500); + return null; + } + pendingG = false; + + switch (e.key) { + case "G": e.preventDefault(); return { type: "scrollBottom" }; + case "n": e.preventDefault(); return { type: "nextHunk" }; + case "N": e.preventDefault(); return { type: "prevHunk" }; + case "s": e.preventDefault(); return { type: "toggleStyle" }; + case "w": e.preventDefault(); return { type: "toggleWhitespace" }; + case "e": e.preventDefault(); return { type: "toggleExpand" }; + case "y": e.preventDefault(); return { type: "copyPath" }; + case "b": e.preventDefault(); return { type: "toggleSidebar" }; + case "h": e.preventDefault(); return { type: "collapseFolder" }; + case "l": e.preventDefault(); return { type: "expandFolder" }; + case "H": e.preventDefault(); return { type: "collapseAll" }; + case "L": e.preventDefault(); return { type: "expandAll" }; + case "j": case "ArrowDown": e.preventDefault(); return { type: "nextFile" }; + case "k": case "ArrowUp": e.preventDefault(); return { type: "prevFile" }; + case "K": e.preventDefault(); return { type: "firstFile" }; + case "J": e.preventDefault(); return { type: "lastFile" }; + } + + return null; +} diff --git a/extensions/piff/src/lib/state.ts b/extensions/piff/src/lib/state.ts new file mode 100644 index 0000000..470fc6a --- /dev/null +++ b/extensions/piff/src/lib/state.ts @@ -0,0 +1,49 @@ +import { writable, derived } from "svelte/store"; + +export interface ChangedFile { + path: string; + oldPath?: string; + status: "added" | "deleted" | "modified" | "renamed" | "copied"; + untracked?: boolean; + additions: number; + deletions: number; +} + +export interface RepoInfo { + name: string; + branch: string; + description: string; + fileCount: number; + cached: number; + total: number; +} + +export interface RenderOptions { + diffStyle: "split" | "unified"; + expandUnchanged: boolean; + ignoreWhitespace: boolean; +} + +export const files = writable([]); +export const repoInfo = writable(null); +export const selectedPath = writable(null); +export const renderOptions = writable({ + diffStyle: "split", + expandUnchanged: true, + ignoreWhitespace: false, +}); +export const helpVisible = writable(false); +export const sidebarVisible = writable(true); +export const toastMessage = writable(null); +export const preloadStatus = writable<{ cached: number; total: number; preloading: boolean }>({ + cached: 0, + total: 0, + preloading: false, +}); + +export const totalAdditions = derived(files, ($files) => + $files.reduce((sum, f) => sum + f.additions, 0) +); +export const totalDeletions = derived(files, ($files) => + $files.reduce((sum, f) => sum + f.deletions, 0) +); diff --git a/extensions/piff/src/main.ts b/extensions/piff/src/main.ts new file mode 100644 index 0000000..b1f2754 --- /dev/null +++ b/extensions/piff/src/main.ts @@ -0,0 +1,6 @@ +import App from "./App.svelte"; +import { mount } from "svelte"; + +const app = mount(App, { target: document.getElementById("app")! }); + +export default app; diff --git a/extensions/piff/ssr-worker-client.ts b/extensions/piff/ssr-worker-client.ts new file mode 100644 index 0000000..5211ec4 --- /dev/null +++ b/extensions/piff/ssr-worker-client.ts @@ -0,0 +1,175 @@ +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, type ChildProcess } from "node:child_process"; +import type { ChangedFile } from "./git.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface RenderOptions { + diffStyle: "split" | "unified"; + expandUnchanged: boolean; + ignoreWhitespace: boolean; +} + +/** + * Combined line count above which we auto-optimize rendering: + * - force split view (unified is very slow for large files) + * - disable expandUnchanged (render only changed hunks) + */ +const LARGE_FILE_THRESHOLD = 3000; + +export function stripTrailingWS(s: string | null): string | null { + if (s == null) return s; + return s.split("\n").map((l) => l.trimEnd()).join("\n"); +} + +/** + * Manages a Bun subprocess that renders diffs via @pierre/diffs SSR. + * Communicates via JSON lines over stdin/stdout. + */ +export class SSRWorker { + private proc: ChildProcess | null = null; + private ready = false; + private nextId = 0; + private pending = new Map< + number, + { resolve: (html: string) => void; reject: (err: Error) => void } + >(); + private buf = ""; + private starting: Promise | null = null; + + async ensure(): Promise { + if (this.proc && this.ready) return; + if (this.starting) return this.starting; + this.starting = new Promise((resolve, reject) => { + this.proc = spawn("bun", ["run", join(__dirname, "ssr-worker.ts")], { + stdio: ["pipe", "pipe", "inherit"], + cwd: __dirname, + }); + + this.proc.stdin!.on("error", () => {}); + + this.proc.on("exit", (code) => { + this.proc = null; + this.ready = false; + this.starting = null; + this.rejectAll("SSR worker exited: " + code); + }); + + this.proc.stdout!.on("data", (chunk: Buffer) => { + this.buf += chunk.toString(); + let nl: number; + while ((nl = this.buf.indexOf("\n")) !== -1) { + const line = this.buf.slice(0, nl); + this.buf = this.buf.slice(nl + 1); + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.id === 0 && msg.ready) { + this.ready = true; + this.starting = null; + resolve(); + continue; + } + const cb = this.pending.get(msg.id); + if (cb) { + this.pending.delete(msg.id); + msg.error + ? cb.reject(new Error(msg.error)) + : cb.resolve(msg.html); + } + } catch {} + } + }); + + setTimeout(() => { + if (!this.ready) { + this.starting = null; + reject(new Error("SSR worker timeout")); + } + }, 15_000); + }); + return this.starting; + } + + /** + * Render a diff to HTML. Caller provides file contents (so it can + * compute content hashes and manage caching externally). + */ + /** + * Send a language preload hint to the worker (fire-and-forget). + */ + preloadLangs(langs: string[]) { + if (!this.proc || !this.ready || langs.length === 0) return; + try { + this.proc.stdin!.write( + JSON.stringify({ id: -1, preloadLangs: langs }) + "\n", + ); + } catch {} + } + + async render( + file: ChangedFile, + oldContent: string | null, + newContent: string | null, + renderOpts?: RenderOptions, + ): Promise { + await this.ensure(); + const id = ++this.nextId; + + // Count combined lines to detect large files + const lineCount = + (oldContent ? oldContent.split("\n").length : 0) + + (newContent ? newContent.split("\n").length : 0); + const isLarge = lineCount > LARGE_FILE_THRESHOLD; + + const options: Record = {}; + if (renderOpts?.diffStyle) { + // Force split for large files (unified rendering is much slower) + options.diffStyle = isLarge ? "split" : renderOpts.diffStyle; + } + if (renderOpts?.expandUnchanged !== undefined) { + // Disable expand for large files (render only changed hunks) + options.expandUnchanged = isLarge ? false : renderOpts.expandUnchanged; + } + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + try { + this.proc!.stdin!.write( + JSON.stringify({ + id, + oldFile: { + name: file.oldPath || file.path, + contents: oldContent ?? "", + }, + newFile: { name: file.path, contents: newContent ?? "" }, + options, + }) + "\n", + ); + } catch (e) { + this.pending.delete(id); + reject(new Error("Failed to write to worker: " + e)); + } + }); + } + + get isBusy() { + return this.pending.size > 0; + } + + kill() { + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + this.ready = false; + } + this.rejectAll("Worker killed"); + } + + private rejectAll(msg: string) { + for (const [, cb] of this.pending) cb.reject(new Error(msg)); + this.pending.clear(); + } +} diff --git a/extensions/piff/ssr-worker.ts b/extensions/piff/ssr-worker.ts new file mode 100644 index 0000000..16a1d8c --- /dev/null +++ b/extensions/piff/ssr-worker.ts @@ -0,0 +1,112 @@ +/** + * Persistent Bun subprocess for SSR diff rendering. + * Protocol: JSON lines over stdin/stdout. + * + * Request: { "id": 1, "oldFile": { "name": "...", "contents": "..." }, "newFile": { ... }, "options": { ... } } + * Response: { "id": 1, "html": "..." } or { "id": 1, "error": "..." } + * Ready: { "id": 0, "ready": true } + * Preload: { "id": -1, "preloadLangs": ["python", "go", ...] } + */ + +import { createInterface } from "node:readline"; +import { preloadDiffHTML } from "@pierre/diffs/ssr"; +import { registerCustomTheme, preloadHighlighter } from "@pierre/diffs"; +import altCatppuccinMocha from "./alt-catppuccin-mocha.json"; + +const THEME_NAME = "alt-catppuccin-mocha"; +registerCustomTheme(THEME_NAME, async () => ({ + ...(altCatppuccinMocha as any), + name: THEME_NAME, + colors: { + ...(altCatppuccinMocha as any).colors, + "editor.background": "#15131E", + }, +})); + +const UNSAFE_CSS = ` + [data-line-type='change-addition'] { + box-shadow: inset 0 1px 0 rgba(166, 227, 161, 0.25), inset 0 -1px 0 rgba(166, 227, 161, 0.25); + } + [data-line-type='change-addition'] + [data-line-type='change-addition'] { + box-shadow: inset 0 -1px 0 rgba(166, 227, 161, 0.25); + } + [data-line-type='change-addition']:has(+ [data-line-type='change-addition']) { + box-shadow: inset 0 1px 0 rgba(166, 227, 161, 0.25); + } + [data-line-type='change-addition'] + [data-line-type='change-addition']:has(+ [data-line-type='change-addition']) { + box-shadow: none; + } + + [data-line-type='change-deletion'] { + box-shadow: inset 0 1px 0 rgba(243, 139, 168, 0.25), inset 0 -1px 0 rgba(243, 139, 168, 0.25); + } + [data-line-type='change-deletion'] + [data-line-type='change-deletion'] { + box-shadow: inset 0 -1px 0 rgba(243, 139, 168, 0.25); + } + [data-line-type='change-deletion']:has(+ [data-line-type='change-deletion']) { + box-shadow: inset 0 1px 0 rgba(243, 139, 168, 0.25); + } + [data-line-type='change-deletion'] + [data-line-type='change-deletion']:has(+ [data-line-type='change-deletion']) { + box-shadow: none; + } +`; + +const DEFAULT_OPTIONS = { + theme: THEME_NAME, + diffIndicators: "none" as const, + diffStyle: "split" as const, + expandUnchanged: true, + unsafeCSS: UNSAFE_CSS, +}; + +// Preload common languages to avoid lazy-load delays during rendering +await preloadHighlighter({ + themes: [THEME_NAME], + langs: [ + "typescript", "javascript", "tsx", "jsx", + "json", "yaml", "toml", "markdown", + "css", "html", "svelte", + "python", "go", "rust", "bash", "shell", + ], +}).catch(() => {}); + +// Warmup with a tiny diff to initialize rendering pipeline +await preloadDiffHTML({ + oldFile: { name: "warmup.ts", contents: "const x = 1;" }, + newFile: { name: "warmup.ts", contents: "const x = 2;" }, + options: DEFAULT_OPTIONS, +}).catch(() => {}); + +process.stdout.write(JSON.stringify({ id: 0, ready: true }) + "\n"); + +const rl = createInterface({ input: process.stdin }); + +for await (const line of rl) { + if (!line.trim()) continue; + let req: any; + try { + req = JSON.parse(line); + } catch { + continue; + } + + // Handle language preload requests + if (req.preloadLangs) { + preloadHighlighter({ themes: [], langs: req.preloadLangs }).catch(() => {}); + continue; + } + + (async (r: any) => { + try { + const opts = { ...DEFAULT_OPTIONS, ...r.options }; + const html = await preloadDiffHTML({ + oldFile: r.oldFile, + newFile: r.newFile, + options: opts, + }); + process.stdout.write(JSON.stringify({ id: r.id, html }) + "\n"); + } catch (e) { + process.stdout.write(JSON.stringify({ id: r.id, error: String(e) }) + "\n"); + } + })(req); +} diff --git a/extensions/piff/svelte.config.js b/extensions/piff/svelte.config.js new file mode 100644 index 0000000..d0e6448 --- /dev/null +++ b/extensions/piff/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/extensions/piff/vite.config.ts b/extensions/piff/vite.config.ts new file mode 100644 index 0000000..d40f2d5 --- /dev/null +++ b/extensions/piff/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [svelte(), viteSingleFile()], + root: "src", + build: { + outDir: "../dist", + emptyOutDir: true, + }, +}); -- 2.51.2 From 5bf664717f7291b2141907b4eefa6c9e129d1b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Tom=C5=A1ej?= Date: Thu, 26 Mar 2026 09:24:01 +0100 Subject: [PATCH 16/66] Refactor server and client components for improved performance and usability - Simplified server logic by removing unnecessary file existence checks and added ETag caching for SPA. - Enhanced file navigation in App.svelte by utilizing sidebar's visual order for better user experience. - Improved DiffView.svelte with a loading spinner and optimized HTML rendering transitions. - Updated Sidebar.svelte to manage folder states and focused item tracking more effectively. - Refined Toolbar.svelte layout for better organization and added keyboard shortcut indicators. - Enhanced API and keyboard handling for clearer functionality and maintainability. - Adjusted SSR worker logic to count lines without allocating arrays, improving performance. - Updated Vite configuration to conditionally apply plugins and set up a proxy for API calls. --- extensions/piff/diff-session.ts | 63 +- extensions/piff/git.ts | 41 +- extensions/piff/package.json | 2 +- extensions/piff/page.html | 1051 ----------------- extensions/piff/server.ts | 47 +- extensions/piff/src/App.svelte | 32 +- .../piff/src/components/DiffView.svelte | 104 +- extensions/piff/src/components/Sidebar.svelte | 207 +++- extensions/piff/src/components/Toolbar.svelte | 165 ++- extensions/piff/src/lib/api.ts | 2 + extensions/piff/src/lib/keyboard.ts | 3 +- extensions/piff/ssr-worker-client.ts | 20 +- extensions/piff/vite.config.ts | 12 +- 13 files changed, 491 insertions(+), 1258 deletions(-) delete mode 100644 extensions/piff/page.html diff --git a/extensions/piff/diff-session.ts b/extensions/piff/diff-session.ts index 2ad08b0..89c9270 100644 --- a/extensions/piff/diff-session.ts +++ b/extensions/piff/diff-session.ts @@ -3,6 +3,8 @@ import { getOldNewContents, bulkGetAllContents, contentHash, + getRepoName, + getCurrentBranch, type ChangedFile, } from "./git.js"; import { @@ -64,6 +66,12 @@ export class DiffSession { renderOptions: RenderOptions; private knownHashes = new Map(); + private contentsCache = new Map(); + private filesByPath = new Map(); + + /** Cached repo metadata (avoids repeated git process spawns). */ + repoName: string; + branch: string; /** Hint: which file the user is currently viewing (for preload ordering). */ currentFile: string | null = null; @@ -73,15 +81,33 @@ export class DiffSession { private cwd: string, ) { this.renderOptions = { ...DEFAULT_RENDER_OPTIONS }; + this.repoName = getRepoName(cwd); + this.branch = getCurrentBranch(cwd); for (let i = 0; i < PRELOAD_WORKERS; i++) { this.preloadWorkers.push(new SSRWorker()); } + // Eagerly start all workers so they're warm when first needed + this.userWorker.ensure().catch(() => {}); + for (const w of this.preloadWorkers) w.ensure().catch(() => {}); } getFiles(): ChangedFile[] { - return (this.filesCache ??= getChangedFiles(this.diffArgs, this.cwd)); + if (!this.filesCache) { + this.filesCache = getChangedFiles(this.diffArgs, this.cwd); + this.filesByPath.clear(); + for (const f of this.filesCache) this.filesByPath.set(f.path, f); + } + return this.filesCache; + } + + getFileByPath(path: string): ChangedFile | undefined { + this.getFiles(); // ensure populated + return this.filesByPath.get(path); } + private _preloadStatusCache: { cached: number; total: number; preloading: boolean } | null = null; + private _preloadStatusTs = 0; + get cachedCount(): number { return this.preloadStatus().cached; } @@ -90,11 +116,24 @@ export class DiffSession { return hash + "\0" + optionsKey(this.renderOptions); } + /** Invalidate preloadStatus throttle when cache changes. */ + private cacheSet(key: string, html: string): void { + this.cache.set(key, html); + this._preloadStatusCache = null; + } + // ── User-facing render (dedicated worker, never blocked) ────── + /** Get old/new contents, using bulk-preload cache when available. */ + private getContents(file: ChangedFile): { oldContent: string | null; newContent: string | null } { + const cached = this.contentsCache.get(file.path); + if (cached) return cached; + return getOldNewContents(file, this.diffArgs, this.cwd); + } + async getDiffHTMLForUser(file: ChangedFile): Promise { this.currentFile = file.path; - let { oldContent, newContent } = getOldNewContents(file, this.diffArgs, this.cwd); + let { oldContent, newContent } = this.getContents(file); ({ oldContent, newContent } = applyWhitespace( oldContent, newContent, this.renderOptions.ignoreWhitespace, )); @@ -111,7 +150,7 @@ export class DiffSession { newContent, this.renderOptions, ); - this.cache.set(key, html); + this.cacheSet(key, html); return html; } @@ -174,6 +213,9 @@ export class DiffSession { const raw = bulkContents.get(file.path); if (!raw) continue; + // Cache raw contents so user requests don't need git show + this.contentsCache.set(file.path, raw); + let { oldContent, newContent } = raw; ({ oldContent, newContent } = applyWhitespace( oldContent, newContent, this.renderOptions.ignoreWhitespace, @@ -206,7 +248,7 @@ export class DiffSession { this.renderOptions, ); if (!this.aborted) { - this.cache.set(item.key, html); + this.cacheSet(item.key, html); } } catch { // Worker error — skip this file @@ -231,11 +273,20 @@ export class DiffSession { refresh() { this.aborted = true; this.filesCache = null; + this.filesByPath.clear(); this.knownHashes.clear(); + this.contentsCache.clear(); + this.repoName = getRepoName(this.cwd); + this.branch = getCurrentBranch(this.cwd); setTimeout(() => this.preloadAll().catch(() => {}), 50); } preloadStatus(): { cached: number; total: number; preloading: boolean } { + // Throttle: recompute at most every 100ms + const now = Date.now(); + if (this._preloadStatusCache && now - this._preloadStatusTs < 100) { + return this._preloadStatusCache; + } const files = this.getFiles(); const optsKey = optionsKey(this.renderOptions); let cached = 0; @@ -243,11 +294,13 @@ export class DiffSession { const hash = this.knownHashes.get(file.path); if (hash && this.cache.has(hash + "\0" + optsKey)) cached++; } - return { + this._preloadStatusCache = { cached, total: files.length, preloading: !this.aborted && cached < files.length, }; + this._preloadStatusTs = now; + return this._preloadStatusCache; } destroy() { diff --git a/extensions/piff/git.ts b/extensions/piff/git.ts index 6b184e4..688145a 100644 --- a/extensions/piff/git.ts +++ b/extensions/piff/git.ts @@ -1,4 +1,4 @@ -import { execSync, spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -16,7 +16,7 @@ export interface ChangedFile { } function git(args: string[], cwd: string): string { - return execSync(`git ${args.join(" ")}`, { + return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: MAX_BUF, stdio: ["pipe", "pipe", "pipe"], }).trim(); } @@ -121,8 +121,9 @@ export function getChangedFiles(diffArgs: string[], cwd: string): ChangedFile[] seen.add(p); let lines = 0; try { - const wc = execSync(`wc -l < '${p.replace(/'/g, "'\\''")}'`, { cwd, encoding: "utf8" }).trim(); - lines = parseInt(wc, 10) || 0; + const content = readFileSync(join(cwd, p), "utf-8"); + lines = content.split("\n").length; + if (content.endsWith("\n")) lines--; } catch {} files.push({ path: p, status: "added", untracked: true, additions: lines, deletions: 0 }); } @@ -151,9 +152,7 @@ export function getChangedFiles(diffArgs: string[], cwd: string): ChangedFile[] export function getFileAtRef(ref: string, filePath: string, cwd: string): string | null { try { - const safeRef = ref.replace(/'/g, "'\\''"); - const safePath = filePath.replace(/'/g, "'\\''"); - return execSync(`git show '${safeRef}:${safePath}'`, { + return execFileSync("git", ["show", `${ref}:${filePath}`], { cwd, encoding: "utf8", maxBuffer: MAX_BUF, stdio: ["pipe", "pipe", "pipe"], }); @@ -210,8 +209,11 @@ export function contentHash( oldContent: string | null, newContent: string | null, ): string { - const combined = (oldContent ?? "\0NULL\0") + "\0" + (newContent ?? "\0NULL\0"); - return Bun.hash(combined).toString(36); + // Hash each part separately to avoid concatenating large strings. + // Mix h1 into h2's seed so collisions require both parts to collide simultaneously. + const h1 = Bun.hash(oldContent ?? "\0NULL\0"); + const h2 = Bun.hash(newContent ?? "\0NULL\0", Number(h1 & 0xFFFFFFFFn)); + return h1.toString(36) + "_" + h2.toString(36); } /** @@ -237,6 +239,7 @@ export function bulkGetFilesAtRef( const results = new Map(); let buf = Buffer.alloc(0); + let bufOffset = 0; // track consumed bytes to avoid repeated subarray let pendingIdx = 0; // Each response from cat-file --batch is: @@ -245,15 +248,15 @@ export function bulkGetFilesAtRef( const processBuffer = () => { while (pendingIdx < paths.length) { // Need at least one line (header) - const nlIdx = buf.indexOf(10); // \n + const nlIdx = buf.indexOf(10, bufOffset); // \n if (nlIdx === -1) break; - const headerLine = buf.subarray(0, nlIdx).toString("utf8"); + const headerLine = buf.subarray(bufOffset, nlIdx).toString("utf8"); const query = `${ref}:${paths[pendingIdx]}`; if (headerLine.endsWith(" missing")) { results.set(query, null); - buf = buf.subarray(nlIdx + 1); + bufOffset = nlIdx + 1; pendingIdx++; continue; } @@ -263,7 +266,7 @@ export function bulkGetFilesAtRef( if (!sizeMatch) { // Unexpected format, skip results.set(query, null); - buf = buf.subarray(nlIdx + 1); + bufOffset = nlIdx + 1; pendingIdx++; continue; } @@ -275,13 +278,21 @@ export function bulkGetFilesAtRef( if (buf.length < contentEnd + 1) break; results.set(query, buf.subarray(contentStart, contentEnd).toString("utf8")); - buf = buf.subarray(contentEnd + 1); + bufOffset = contentEnd + 1; pendingIdx++; } + // Compact buffer when we've consumed a lot + if (bufOffset > 65536) { + buf = buf.subarray(bufOffset); + bufOffset = 0; + } }; proc.stdout.on("data", (chunk: Buffer) => { - buf = Buffer.concat([buf, chunk]); + buf = bufOffset > 0 + ? Buffer.concat([buf.subarray(bufOffset), chunk]) + : Buffer.concat([buf, chunk]); + bufOffset = 0; processBuffer(); }); diff --git a/extensions/piff/package.json b/extensions/piff/package.json index 7e284d8..689d444 100644 --- a/extensions/piff/package.json +++ b/extensions/piff/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "build": "vite build", - "dev": "vite dev" + "dev": "bun --watch cli.ts & bun ./node_modules/.bin/vite dev & wait" }, "dependencies": { "@pierre/diffs": "latest" diff --git a/extensions/piff/page.html b/extensions/piff/page.html deleted file mode 100644 index e720583..0000000 --- a/extensions/piff/page.html +++ /dev/null @@ -1,1051 +0,0 @@ - - - - - -piff - - - -
-
- - - - - - · - - - · - - - - - - - - - -
-
- -
-
-
-
Select a file
- -
-
-
- -
-
-
-

Keyboard Shortcuts

-
-

Files

-
j / ↓Next file
-
k / ↑Previous file
-
JLast file
-
KFirst file
-
yCopy file path
-
bToggle sidebar
-
h / lCollapse / expand folder
-
H / LCollapse / expand all
-
-
-

Diff navigation

-
nNext changed hunk
-
NPrevious changed hunk
-
ggScroll to top
-
GScroll to bottom
-
Ctrl+d / uScroll ½ page down / up
-
Ctrl+f / bScroll full page down / up
-
-
-

Display

-
sToggle split / unified
-
wToggle ignore whitespace
-
eToggle expand unchanged
-
-
-

General

-
?Toggle this help
-
-
Press ? or Esc to close
-
-
- - - - diff --git a/extensions/piff/server.ts b/extensions/piff/server.ts index 66657b1..94304ab 100644 --- a/extensions/piff/server.ts +++ b/extensions/piff/server.ts @@ -1,20 +1,15 @@ import { createServer, type ServerResponse } from "node:http"; -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { getRepoName, getCurrentBranch } from "./git.js"; import { DiffSession } from "./diff-session.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -function loadPageHTML(): string { - // Try built Svelte app first, fall back to legacy page.html - const distPath = join(__dirname, "dist", "index.html"); - if (existsSync(distPath)) return readFileSync(distPath, "utf-8"); - return readFileSync(join(__dirname, "page.html"), "utf-8"); -} +const PAGE_HTML = readFileSync(join(__dirname, "dist", "index.html"), "utf-8"); +const PAGE_HTML_BUF = Buffer.from(PAGE_HTML, "utf-8"); +const PAGE_ETAG = `"${Bun.hash(PAGE_HTML).toString(36)}"`; -const PAGE_HTML = loadPageHTML(); export interface ServerOptions { port: number; @@ -37,9 +32,20 @@ export function stopServer() { } } -export function startServer(opts: ServerOptions): Promise { +async function killPort(port: number) { + try { + const proc = Bun.spawn(["lsof", "-ti:" + port], { stdout: "pipe", stderr: "ignore" }); + const text = await new Response(proc.stdout).text(); + for (const pid of text.trim().split("\n").filter(Boolean)) { + try { process.kill(+pid, "SIGKILL"); } catch {} + } + } catch {} +} + +export async function startServer(opts: ServerOptions): Promise { const { port, diffArgs, description, cwd } = opts; stopServer(); + await killPort(port); const session = new DiffSession(diffArgs, cwd); activeSession = session; @@ -72,8 +78,8 @@ export function startServer(opts: ServerOptions): Promise { if (p === "/api/info") { const files = session.getFiles(); json(res, { - name: getRepoName(cwd), - branch: getCurrentBranch(cwd), + name: session.repoName, + branch: session.branch, description, fileCount: files.length, cached: session.cachedCount, @@ -145,7 +151,7 @@ export function startServer(opts: ServerOptions): Promise { if (p.startsWith("/api/diff/")) { const filePath = decodeURIComponent(p.slice(10)); - const file = session.getFiles().find((f) => f.path === filePath); + const file = session.getFileByPath(filePath); if (!file) { error(res, 404, "Not found"); return; @@ -159,13 +165,22 @@ export function startServer(opts: ServerOptions): Promise { return; } - // Serve the SPA + // Serve the SPA with ETag caching + if (req.headers["if-none-match"] === PAGE_ETAG) { + res.writeHead(304); + res.end(); + return; + } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", + "ETag": PAGE_ETAG, + "Cache-Control": "no-cache", }); - res.end(PAGE_HTML); + res.end(PAGE_HTML_BUF); } catch (e) { - error(res, 500, String(e)); + if (!res.headersSent) { + error(res, 500, String(e)); + } } }); diff --git a/extensions/piff/src/App.svelte b/extensions/piff/src/App.svelte index 3c67172..0a86883 100644 --- a/extensions/piff/src/App.svelte +++ b/extensions/piff/src/App.svelte @@ -27,11 +27,6 @@ let sidebar: Sidebar; let diffView: DiffView; - // Use sidebar's visual DFS order for j/k navigation - function getFilePaths(): string[] { - return sidebar?.getOrderedPaths() ?? $files.map((f) => f.path); - } - async function handleRefresh() { await refresh(); diffView?.clearCache(); @@ -51,26 +46,31 @@ } function navigateFile(dir: "next" | "prev" | "first" | "last") { - const paths = getFilePaths(); - if (!paths.length) return; - const idx = $selectedPath ? paths.indexOf($selectedPath) : -1; + const items = sidebar?.getOrderedItems() ?? []; + if (!items.length) return; + const currentKey = sidebar?.getFocusedKey(); + const idx = currentKey ? items.findIndex(i => i.key === currentKey) : -1; let next: number; switch (dir) { case "next": - next = idx < paths.length - 1 ? idx + 1 : 0; + next = idx < items.length - 1 ? idx + 1 : 0; break; case "prev": - next = idx > 0 ? idx - 1 : paths.length - 1; + next = idx > 0 ? idx - 1 : items.length - 1; break; case "first": next = 0; break; case "last": - next = paths.length - 1; + next = items.length - 1; break; } - $selectedPath = paths[next]; - sidebar?.scrollFileIntoView(paths[next]); + const item = items[next]; + sidebar?.setFocusedKey(item.key); + sidebar?.scrollItemIntoView(item.key); + if (item.type === "file") { + $selectedPath = item.key; + } } /** Apply a render option change and re-render current diff. @@ -123,8 +123,8 @@ } break; case "toggleSidebar": $sidebarVisible = !$sidebarVisible; break; - case "collapseFolder": sidebar?.collapseSelectedFolder(); break; - case "expandFolder": sidebar?.expandSelectedFolder(); break; + case "collapseFolder": sidebar?.collapseFocused(); break; + case "expandFolder": sidebar?.expandFocused(); break; case "collapseAll": sidebar?.collapseAll(); break; case "expandAll": sidebar?.expandAll(); break; case "nextHunk": diffView?.navigateHunk(1); break; @@ -181,6 +181,7 @@ if (fl.length > 0) { $selectedPath = fl[0].path; + sidebar?.setFocusedKey(fl[0].path); } startPreloadStream(); @@ -210,6 +211,7 @@ + diff --git a/extensions/piff/src/lib/api.ts b/extensions/piff/src/lib/api.ts index e0511ef..99a5fca 100644 --- a/extensions/piff/src/lib/api.ts +++ b/extensions/piff/src/lib/api.ts @@ -43,3 +43,5 @@ export async function refresh(): Promise { } + + diff --git a/extensions/piff/src/lib/keyboard.ts b/extensions/piff/src/lib/keyboard.ts index 3ff37ca..0022dfd 100644 --- a/extensions/piff/src/lib/keyboard.ts +++ b/extensions/piff/src/lib/keyboard.ts @@ -1,5 +1,5 @@ /** - * Keyboard handler – dispatches actions based on key presses. + * Keyboard handler - dispatches actions based on key presses. * Returns an action descriptor so the caller (App.svelte) can handle side effects. */ @@ -47,7 +47,6 @@ export function handleKeydown(e: KeyboardEvent): Action { return { type: "toggleHelp" }; // App will check if help is visible } - // Ctrl combos if (e.ctrlKey) { switch (e.key) { case "d": e.preventDefault(); return { type: "scrollHalfDown" }; diff --git a/extensions/piff/ssr-worker-client.ts b/extensions/piff/ssr-worker-client.ts index 5211ec4..e7efbaa 100644 --- a/extensions/piff/ssr-worker-client.ts +++ b/extensions/piff/ssr-worker-client.ts @@ -18,9 +18,17 @@ export interface RenderOptions { */ const LARGE_FILE_THRESHOLD = 3000; +function countLines(s: string | null): number { + if (!s) return 0; + let count = 1; + let pos = 0; + while ((pos = s.indexOf("\n", pos)) !== -1) { count++; pos++; } + return count; +} + export function stripTrailingWS(s: string | null): string | null { if (s == null) return s; - return s.split("\n").map((l) => l.trimEnd()).join("\n"); + return s.replace(/[^\S\n]+$/gm, ""); } /** @@ -117,10 +125,8 @@ export class SSRWorker { await this.ensure(); const id = ++this.nextId; - // Count combined lines to detect large files - const lineCount = - (oldContent ? oldContent.split("\n").length : 0) + - (newContent ? newContent.split("\n").length : 0); + // Count combined lines to detect large files (without allocating arrays) + const lineCount = countLines(oldContent) + countLines(newContent); const isLarge = lineCount > LARGE_FILE_THRESHOLD; const options: Record = {}; @@ -153,10 +159,6 @@ export class SSRWorker { }); } - get isBusy() { - return this.pending.size > 0; - } - kill() { if (this.proc) { try { diff --git a/extensions/piff/vite.config.ts b/extensions/piff/vite.config.ts index d40f2d5..18d243c 100644 --- a/extensions/piff/vite.config.ts +++ b/extensions/piff/vite.config.ts @@ -2,11 +2,17 @@ import { defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; import { viteSingleFile } from "vite-plugin-singlefile"; -export default defineConfig({ - plugins: [svelte(), viteSingleFile()], +export default defineConfig(({ command }) => ({ + plugins: [svelte(), ...(command === "build" ? [viteSingleFile()] : [])], root: "src", build: { outDir: "../dist", emptyOutDir: true, }, -}); + server: { + port: 5492, + proxy: { + "/api": "http://localhost:5491", + }, + }, +})); -- 2.51.2 From ce8f455caf59a4b341902f96f8664a37404437bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Tom=C5=A1ej?= Date: Fri, 27 Mar 2026 05:50:50 +0100 Subject: [PATCH 17/66] feat: add Aine extension for code review and comment management - Implemented a new Aine extension that provides commands for opening a diff viewer, AI reviews, and resolving comments. - Created an in-memory thread store to manage review threads and comments with CRUD operations. - Defined shared types for threads, comments, and themes to ensure consistency across the application. - Added a GitHub dark theme configuration for improved UI aesthetics. - Configured Vite for building and serving the application with a proxy for API requests. --- AINE-PLAN.md | 150 ++++ aine/bun.lock | 426 ++++++++++ aine/cli.ts | 279 +++++++ aine/config.ts | 82 ++ aine/diff-session.ts | 403 +++++++++ aine/dist/index.html | 77 ++ aine/git.ts | 363 ++++++++ aine/lru-cache.ts | 57 ++ aine/notes/diffity-analysis.md | 127 +++ aine/notes/plan.md | 88 ++ aine/notes/review-workflow-brainstorm.md | 503 +++++++++++ aine/package.json | 32 + aine/routes.ts | 340 ++++++++ aine/server.ts | 152 ++++ aine/session-cli.ts | 299 +++++++ aine/session-store.ts | 105 +++ aine/skills/aine-review/SKILL.md | 141 ++++ aine/src/App.svelte | 251 ++++++ aine/src/components/CommentInput.svelte | 83 ++ aine/src/components/CommentsPanel.svelte | 444 ++++++++++ aine/src/components/DiffView.svelte | 682 +++++++++++++++ aine/src/components/HelpOverlay.svelte | 129 +++ aine/src/components/Sidebar.svelte | 204 +++++ aine/src/components/Toast.svelte | 45 + aine/src/components/Toolbar.svelte | 293 +++++++ aine/src/index.html | 12 + aine/src/lib/api.ts | 106 +++ aine/src/lib/diff-highlights.ts | 177 ++++ aine/src/lib/events.ts | 50 ++ aine/src/lib/file-tree.ts | 134 +++ aine/src/lib/keyboard.ts | 97 +++ aine/src/lib/state.ts | 53 ++ aine/src/lib/theme-apply.ts | 21 + aine/src/main.ts | 6 + aine/ssr-worker-client.ts | 190 +++++ aine/ssr-worker.ts | 138 +++ aine/svelte.config.js | 5 + aine/tests/api/server.test.ts | 132 +++ aine/tests/api/session.test.ts | 125 +++ aine/tests/api/threads.test.ts | 164 ++++ aine/tests/coverage/base.css | 224 +++++ aine/tests/coverage/block-navigation.js | 87 ++ aine/tests/coverage/clover.xml | 78 ++ aine/tests/coverage/coverage-final.json | 3 + aine/tests/coverage/favicon.png | Bin 0 -> 445 bytes aine/tests/coverage/index.html | 131 +++ aine/tests/coverage/prettify.css | 1 + aine/tests/coverage/prettify.js | 2 + aine/tests/coverage/setup-repo.ts.html | 400 +++++++++ aine/tests/coverage/sort-arrow-sprite.png | Bin 0 -> 138 bytes aine/tests/coverage/sorter.js | 210 +++++ aine/tests/coverage/test-server.ts.html | 409 +++++++++ aine/tests/e2e/diff-view.test.ts | 115 +++ aine/tests/e2e/helpers.ts | 54 ++ aine/tests/e2e/navigation.test.ts | 123 +++ aine/tests/e2e/threads.test.ts | 134 +++ aine/tests/fixtures/setup-repo.ts | 105 +++ aine/tests/fixtures/test-server.ts | 108 +++ aine/tests/fixtures/vitest-setup.ts | 16 + aine/tests/playwright.config.ts | 18 + aine/tests/unit/git.test.ts | 179 ++++ aine/tests/unit/lru-cache.test.ts | 97 +++ aine/tests/vitest.config.ts | 12 + aine/theme.ts | 149 ++++ aine/themes/catppuccin-mocha.json | 973 ++++++++++++++++++++++ aine/themes/github-dark.json | 21 + aine/thread-store.ts | 154 ++++ aine/types.ts | 95 +++ aine/vite.config.ts | 18 + extensions/aine/index.ts | 233 ++++++ package.json | 2 +- 71 files changed, 11315 insertions(+), 1 deletion(-) create mode 100644 AINE-PLAN.md create mode 100644 aine/bun.lock create mode 100644 aine/cli.ts create mode 100644 aine/config.ts create mode 100644 aine/diff-session.ts create mode 100644 aine/dist/index.html create mode 100644 aine/git.ts create mode 100644 aine/lru-cache.ts create mode 100644 aine/notes/diffity-analysis.md create mode 100644 aine/notes/plan.md create mode 100644 aine/notes/review-workflow-brainstorm.md create mode 100644 aine/package.json create mode 100644 aine/routes.ts create mode 100644 aine/server.ts create mode 100644 aine/session-cli.ts create mode 100644 aine/session-store.ts create mode 100644 aine/skills/aine-review/SKILL.md create mode 100644 aine/src/App.svelte create mode 100644 aine/src/components/CommentInput.svelte create mode 100644 aine/src/components/CommentsPanel.svelte create mode 100644 aine/src/components/DiffView.svelte create mode 100644 aine/src/components/HelpOverlay.svelte create mode 100644 aine/src/components/Sidebar.svelte create mode 100644 aine/src/components/Toast.svelte create mode 100644 aine/src/components/Toolbar.svelte create mode 100644 aine/src/index.html create mode 100644 aine/src/lib/api.ts create mode 100644 aine/src/lib/diff-highlights.ts create mode 100644 aine/src/lib/events.ts create mode 100644 aine/src/lib/file-tree.ts create mode 100644 aine/src/lib/keyboard.ts create mode 100644 aine/src/lib/state.ts create mode 100644 aine/src/lib/theme-apply.ts create mode 100644 aine/src/main.ts create mode 100644 aine/ssr-worker-client.ts create mode 100644 aine/ssr-worker.ts create mode 100644 aine/svelte.config.js create mode 100644 aine/tests/api/server.test.ts create mode 100644 aine/tests/api/session.test.ts create mode 100644 aine/tests/api/threads.test.ts create mode 100644 aine/tests/coverage/base.css create mode 100644 aine/tests/coverage/block-navigation.js create mode 100644 aine/tests/coverage/clover.xml create mode 100644 aine/tests/coverage/coverage-final.json create mode 100644 aine/tests/coverage/favicon.png create mode 100644 aine/tests/coverage/index.html create mode 100644 aine/tests/coverage/prettify.css create mode 100644 aine/tests/coverage/prettify.js create mode 100644 aine/tests/coverage/setup-repo.ts.html create mode 100644 aine/tests/coverage/sort-arrow-sprite.png create mode 100644 aine/tests/coverage/sorter.js create mode 100644 aine/tests/coverage/test-server.ts.html create mode 100644 aine/tests/e2e/diff-view.test.ts create mode 100644 aine/tests/e2e/helpers.ts create mode 100644 aine/tests/e2e/navigation.test.ts create mode 100644 aine/tests/e2e/threads.test.ts create mode 100644 aine/tests/fixtures/setup-repo.ts create mode 100644 aine/tests/fixtures/test-server.ts create mode 100644 aine/tests/fixtures/vitest-setup.ts create mode 100644 aine/tests/playwright.config.ts create mode 100644 aine/tests/unit/git.test.ts create mode 100644 aine/tests/unit/lru-cache.test.ts create mode 100644 aine/tests/vitest.config.ts create mode 100644 aine/theme.ts create mode 100644 aine/themes/catppuccin-mocha.json create mode 100644 aine/themes/github-dark.json create mode 100644 aine/thread-store.ts create mode 100644 aine/types.ts create mode 100644 aine/vite.config.ts create mode 100644 extensions/aine/index.ts diff --git a/AINE-PLAN.md b/AINE-PLAN.md new file mode 100644 index 0000000..4d27037 --- /dev/null +++ b/AINE-PLAN.md @@ -0,0 +1,150 @@ +# Aine – Standalone Code Review Tool + +## Vize + +Aine je agent-agnostický diff viewer a code review tool. Funguje jako standalone CLI (`npm i -g aine`), bez závislosti na konkrétním AI harnessu. Agenti (Claude Code, Pi, Codex, OpenCode) se integrují přes skill file, který je naučí ovládat běžící aine instanci přes `aine session` CLI příkazy. + +**Inspirace:** [diffity](https://github.com/kamranahmedse/diffity), [plannotator](https://github.com/backnotprop/plannotator), [hunk](https://github.com/modem-dev/hunk) + +--- + +## Fáze 1 – Osamostatnění + +Cíl: aine funguje jako standalone CLI nástroj, nezávisle na pi. + +### 1.1 Standalone CLI balík +- [ ] Vytvořit nový top-level adresář `aine/` (nebo samostatné repo) s vlastním `package.json` (`name: "aine"`, `bin: { aine: "./cli.ts" }`) +- [ ] Přesunout core logiku z `extensions/aine/` – `git.ts`, `server.ts`, `diff-session.ts`, `ssr-worker-client.ts`, `ssr-worker.ts`, `lru-cache.ts` +- [ ] Přesunout frontend (`src/`) a `vite.config.ts`, `svelte.config.js` +- [ ] CLI entry point (`cli.ts`) s plnou command strukturou: + - `aine` / `aine diff` – working tree changes + - `aine diff --staged` / `aine diff --watch` + - `aine show [ref]` – zobrazí commit + - `aine diff main..feature` – branch comparison + - `aine --port ` / `--no-open` / `--dark` / `--unified` +- [ ] `npm i -g aine` funguje, `aine` spustí server a otevře browser +- [ ] Testy: CLI parsování, git ref validace + +### 1.2 Session daemon & remote control API +- [ ] Při startu aine instance se registruje u lokálního session store (soubor v `~/.config/aine/sessions/` nebo lightweight loopback HTTP daemon) +- [ ] Každá session má: `id`, `repo`, `port`, `pid`, `startedAt`, `diffArgs` +- [ ] `aine session` subcommands: + - `aine session list [--json]` – výpis běžících instancí + - `aine session context --repo .` – vrátí aktuální stav (repo, branch, soubory, vybraný soubor, diff args) + - `aine session navigate --repo . --file [--hunk N | --line N]` – přesune focus v UI + - `aine session reload --repo . [-- diff --staged]` – swap obsahu bez restartu + - `aine session comment add --repo . --file --line --summary "..." [--severity must-fix|suggestion|nit] [--author agent]` + - `aine session comment list --repo . [--file ] [--json]` + - `aine session comment resolve --repo . ` + - `aine session comment rm --repo . ` + - `aine session comment clear --repo . [--file ] --yes` +- [ ] Session commands komunikují s běžící instancí přes HTTP API (`/api/session/*`) +- [ ] Auto-cleanup: při ukončení aine procesu se session odregistruje + +### 1.3 Skill file pro agenty +- [ ] Vytvořit `skills/aine-review/SKILL.md` – naučí agenta workflow: + 1. `aine session list` → najdi živou session + 2. `aine session context --repo .` → zjisti co se reviewuje + 3. `aine session navigate ...` → naviguj na zajímavé místo + 4. `aine session comment add ...` → zanech komentář + 5. Opakuj pro všechny findings +- [ ] Skill je agent-agnostický – funguje v Claude Code, Pi, Codex, OpenCode +- [ ] Dokumentovat typické prompty: "review my changes", "leave comments on the diff" + +### 1.4 Pi extension jako tenký wrapper +- [ ] Zredukovat `extensions/aine/index.ts` na: + - `/aine [args]` → spustí `aine` CLI jako subprocess + - `/aine-review` → spustí `aine` + zavolá review workflow přes session API + - `/aine-resolve` → přečte komentáře přes `aine session comment list`, provede změny +- [ ] Extension závisí na globálně nainstalovaném `aine` (s fallbackem na `npx aine`) +- [ ] `pi.on("session_shutdown")` → uklidí aine process + +--- + +## Fáze 2 – Review Core + +Cíl: kompletní review loop – komentáře, resolve, AI review. + +### 2.1 Inline komentáře v UI +- [ ] Backend: `DiffSession` drží komentáře in-memory (`Map`) + - `Comment: { id, file, line, side, summary, severity?, author, createdAt, resolved }` +- [ ] API endpointy: + - `GET /api/comments[?file=...]` – seznam komentářů + - `POST /api/comments` – přidání komentáře + - `PATCH /api/comments/:id` – resolve/unresolve + - `DELETE /api/comments/:id` – smazání +- [ ] SSE stream `/api/comments/stream` – live updates (agent přidá komentář → UI se updatne) +- [ ] Frontend: kliknutí na číslo řádku v diff view → otevře inline textarea +- [ ] Zobrazení komentářů jako inline bubliny pod příslušným řádkem +- [ ] Severity color coding: `must-fix` (červená), `suggestion` (žlutá), `nit` (šedá) +- [ ] Keyboard: `c` otevře komentář na aktuálním řádku, `Ctrl+Enter` odešle + +### 2.2 Komentáře v sidebar +- [ ] Badge u každého souboru: počet neresolved komentářů +- [ ] Highest severity indikátor (červená/žlutá/šedá tečka) +- [ ] Filter: "show files with comments only" +- [ ] Resolved komentáře: šedé, collapsed, toggle "show resolved" + +### 2.3 Agent review přes session API +- [ ] `aine session comment add` zapisuje komentáře do běžící instance → UI se updatne přes SSE +- [ ] `aine session comment list --json` vrací strukturovaný výstup pro agenta +- [ ] Agent context JSON import: `aine diff --agent-context notes.json` + - Formát: `[{ file, line, summary, severity?, rationale? }]` + +### 2.4 Resolve workflow +- [ ] UI: tlačítko "Resolve" na každém komentáři → PATCH `/api/comments/:id` +- [ ] `aine session comment resolve ` – agent může resolvnout po provedení opravy +- [ ] Bulk resolve: "Resolve all in file" / "Resolve all" + +--- + +## Fáze 3 – Polish & rozšířené funkce + +### 3.1 Watch mode +- [ ] `aine --watch` / `aine diff --watch` – `fs.watch` na working tree +- [ ] Debounced refresh (300ms), zachování scroll pozice a vybraného souboru +- [ ] SSE notifikace do UI při změně + +### 3.2 Diff type switcher v UI +- [ ] Toolbar dropdown/toggle: Uncommitted / Staged / Unstaged / Last commit / Branch +- [ ] Přepnutí → `session.reload(newDiffArgs)` → refresh bez restartu serveru +- [ ] Odpovídá `aine session reload` z CLI + +### 3.3 Multi-instance +- [ ] Auto-assign portů z rozsahu (5491, 5492, ...) +- [ ] `aine session list` zobrazí všechny instance +- [ ] Detekce existující instance pro stejný repo → otevře existující místo nové +- [ ] `aine --new` – kill existující a start nový + +### 3.4 Config file +- [ ] `~/.config/aine/config.toml`: + ```toml + theme = "catppuccin" # catppuccin, github-dark, ... + mode = "split" # split, unified + port_range = [5491, 5500] + watch = false + open_browser = true + ``` +- [ ] CLI flags overridují config +- [ ] Per-repo config: `.aine/config.toml` + +### 3.5 Git integrace +- [ ] `aine pager` mode – funguje jako `git config core.pager "aine pager"` +- [ ] `aine difftool` – `git config diff.tool aine` +- [ ] Patch input: `git diff | aine patch -` + +### 3.6 GitHub PR review +- [ ] `aine pr https://github.com/.../pull/123` +- [ ] Vyžaduje `gh` CLI +- [ ] Stáhne PR diff, zobrazí v aine +- [ ] Push komentářů zpět na GitHub jako PR review comments + +--- + +## Rozhodnutí k diskusi + +1. **Mono-repo vs. samostatné repo** – zůstane aine v `pi-ext` nebo nové repo? +2. **Runtime** – zůstat na Bun, nebo přejít na Node.js pro širší kompatibilitu? +3. **Název balíku** – `aine` na npm (je volný?) +4. **Licence** – MIT vs. jiná +5. **Session store** – soubory v `~/.config/aine/sessions/` (jednodušší) vs. loopback daemon (jako hunk)? diff --git a/aine/bun.lock b/aine/bun.lock new file mode 100644 index 0000000..8b6e9a9 --- /dev/null +++ b/aine/bun.lock @@ -0,0 +1,426 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "aine", + "dependencies": { + "@pierre/diffs": "latest", + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@vitest/coverage-v8": "^4.1.2", + "svelte": "^5.55.0", + "vite": "^8.0.2", + "vite-plugin-singlefile": "^2.3.2", + "vitest": "^4.1.2", + }, + }, + }, + "packages": { + "@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/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@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=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@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=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + + "@pierre/diffs": ["@pierre/diffs@1.1.4", "", { "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-qkLr0ZNhWQuYUozIhOo6Zxm4JhtwLyRXXarwLNDksgSqMcVf8QTtLVm8/TViwdxzrwNPoIEt/FDJpFWYkZjhhg=="], + + "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], + + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="], + + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@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/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="], + + "@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="], + + "@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="], + + "@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="], + + "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "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=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "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=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + + "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=="], + + "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=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "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=="], + + "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=="], + + "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], + + "rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "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=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "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=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "svelte": ["svelte@5.55.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "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=="], + + "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=="], + + "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], + + "vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.2", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.59.0", "vite": "^5.4.11 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-b8SxCi/gG7K298oJDcKOuZeU6gf6wIcCJAaEqUmmZXdjfuONlkyNyWZC3tEbN6QockRCNUd3it9eGTtpHGoYmg=="], + + "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=="], + + "vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + } +} diff --git a/aine/cli.ts b/aine/cli.ts new file mode 100644 index 0000000..5eb2749 --- /dev/null +++ b/aine/cli.ts @@ -0,0 +1,279 @@ +#!/usr/bin/env bun +/** + * Aine – Agent-agnostic diff viewer and code review tool. + * + * Usage: + * aine → working tree changes + * aine diff → working tree changes + * aine diff --staged → staged changes only + * aine diff main → diff against main + * aine diff main..feature → compare two branches + * aine show → last commit + * aine show HEAD~3 → specific commit + * aine session list → list running instances + * aine session context --repo . → current session state + */ + +import { isGitRepo, parseRefs, getRepoRoot } from "./git.js"; +import { startServer, stopServer } from "./server.js"; +import { sessionCommand } from "./session-cli.js"; +import { loadConfig } from "./config.js"; + +const DEFAULT_PORT = 5491; + +interface CliOptions { + command: "diff" | "show" | "session" | "help" | "version"; + refs: string[]; + port: number; + portExplicit: boolean; + noOpen: boolean; + staged: boolean; + watch: boolean; + unified: boolean; + forceNew: boolean; + sessionArgs: string[]; +} + +function printHelp() { + console.log(` +aine - Agent-agnostic diff viewer and code review tool + +USAGE + aine Review working tree changes + aine diff [options] [refs] Review diffs + aine show [ref] Review a commit + aine session Control running instances + aine help Show this help + aine version Show version + +DIFF OPTIONS + --staged Show staged changes only + --watch Auto-reload on file changes + --port Custom port (default: auto-assigned) + --no-open Don't open browser + --unified Start in unified view (default: split) + --new Stop existing instance and start fresh + +DIFF EXAMPLES + aine Working tree changes (staged + unstaged + untracked) + aine diff --staged Staged changes only + aine diff main Compare current branch against main + aine diff main..feature Compare two branches + aine diff HEAD~3 Last 3 commits + aine show Review last commit + aine show HEAD~2 Review specific commit + +SESSION COMMANDS + aine session list [--json] + aine session context --repo + aine session navigate --repo --file [--hunk N | --line N] + aine session reload --repo [-- diff --staged] + aine session comment add --repo --file --line --summary "..." + aine session comment list --repo [--json] + aine session comment resolve --repo + aine session comment rm --repo + aine session comment clear --repo --yes +`.trim()); +} + +function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { + command: "diff", + refs: [], + port: DEFAULT_PORT, + portExplicit: false, + noOpen: false, + staged: false, + watch: false, + unified: false, + forceNew: false, + sessionArgs: [], + }; + + const args = argv.slice(2); + if (args.length === 0) return opts; + + let i = 0; + + // Parse command + const first = args[0]; + if (first === "help" || first === "--help" || first === "-h") { + opts.command = "help"; + return opts; + } + if (first === "version" || first === "--version" || first === "-v") { + opts.command = "version"; + return opts; + } + if (first === "session") { + opts.command = "session"; + opts.sessionArgs = args.slice(1); + return opts; + } + if (first === "show") { + opts.command = "show"; + i = 1; + } else if (first === "diff") { + opts.command = "diff"; + i = 1; + } + // else: no subcommand, treat remaining as diff refs + + // Parse remaining args + for (; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case "--staged": + opts.staged = true; + break; + case "--watch": + opts.watch = true; + break; + case "--no-open": + opts.noOpen = true; + break; + case "--unified": + opts.unified = true; + break; + case "--new": + opts.forceNew = true; + break; + case "--port": + i++; + opts.port = parseInt(args[i], 10); + opts.portExplicit = true; + if (isNaN(opts.port)) { + console.error("Error: --port requires a number"); + process.exit(1); + } + break; + default: + if (arg.startsWith("-")) { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } + opts.refs.push(arg); + break; + } + } + + return opts; +} + +async function main() { + const opts = parseArgs(process.argv); + + if (opts.command === "help") { + printHelp(); + process.exit(0); + } + + if (opts.command === "version") { + const pkg = await import("./package.json"); + console.log(`aine v${pkg.version}`); + process.exit(0); + } + + if (opts.command === "session") { + await sessionCommand(opts.sessionArgs); + process.exit(0); + } + + // diff or show + const cwd = process.cwd(); + + if (!isGitRepo(cwd)) { + console.error("Error: not a git repository"); + process.exit(1); + } + + const repoRoot = getRepoRoot(cwd); + + // Load config (global ← per-repo ← CLI flags) + const config = loadConfig(repoRoot); + + // Apply config defaults where CLI didn't set explicit values + if (!opts.portExplicit) opts.port = config.port; + if (!opts.unified && config.mode === "unified") opts.unified = true; + if (!opts.watch && config.watch) opts.watch = true; + if (opts.noOpen === false && !config.open_browser) opts.noOpen = true; + + // Check for existing session for this repo + const { listSessions, findSessionByRepo } = await import("./session-store.js"); + const existing = findSessionByRepo(repoRoot); + + if (existing && !opts.forceNew) { + // Reuse existing session — just open the browser + console.log(`aine already running → http://localhost:${existing.port}`); + if (!opts.noOpen) { + try { + Bun.spawn(["open", `http://localhost:${existing.port}`], { stdout: "ignore", stderr: "ignore" }); + } catch {} + } + process.exit(0); + } + + if (existing && opts.forceNew) { + // Kill existing session + try { process.kill(existing.pid, "SIGTERM"); } catch {} + await new Promise(r => setTimeout(r, 500)); + } + + // Build diffArgs based on command + let diffArgs: string[]; + let description: string; + + if (opts.command === "show") { + const ref = opts.refs[0] || "HEAD"; + diffArgs = [`${ref}~1..${ref}`]; + description = `Commit ${ref}`; + } else if (opts.staged) { + diffArgs = ["--staged"]; + description = "Staged changes"; + } else { + const refs = opts.refs; + const parsed = parseRefs(refs, repoRoot); + if ("error" in parsed) { + console.error(`Error: invalid git ref '${parsed.error}'`); + process.exit(1); + } + diffArgs = parsed.diffArgs; + description = parsed.description; + } + + // Auto-assign port if not explicitly set + let port = opts.port; + if (!opts.portExplicit) { + const sessions = listSessions(); + const usedPorts = new Set(sessions.map(s => s.port)); + const basePort = config.port; + for (let p = basePort; p < basePort + 100; p++) { + if (!usedPorts.has(p)) { + port = p; + break; + } + } + } + + try { + await startServer({ + port, + diffArgs, + description, + cwd: repoRoot, + unified: opts.unified, + noOpen: opts.noOpen, + watch: opts.watch, + theme: config.theme, + expandUnchanged: config.expand_unchanged, + ignoreWhitespace: config.ignore_whitespace, + }); + console.log(`aine → http://localhost:${port}${opts.watch ? " (watching)" : ""}`); + console.log("Press Ctrl+C to stop"); + } catch (err) { + console.error("Failed to start aine:", err); + process.exit(1); + } +} + +main(); diff --git a/aine/config.ts b/aine/config.ts new file mode 100644 index 0000000..5f9ffe6 --- /dev/null +++ b/aine/config.ts @@ -0,0 +1,82 @@ +/** + * Aine configuration loader. + * + * Reads from (in order of priority, later wins): + * 1. ~/.config/aine/config.toml (global) + * 2. .aine/config.toml (per-repo, relative to repo root) + * 3. CLI flags (highest priority, applied by caller) + * + * Simple TOML subset parser — no dependencies needed. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export interface AineConfig { + mode: "split" | "unified"; + port: number; + watch: boolean; + open_browser: boolean; + theme: string; + expand_unchanged: boolean; + ignore_whitespace: boolean; +} + +const DEFAULTS: AineConfig = { + mode: "split", + port: 5491, + watch: false, + open_browser: true, + theme: "catppuccin-mocha", + expand_unchanged: true, + ignore_whitespace: false, +}; + +const GLOBAL_CONFIG = join(homedir(), ".config", "aine", "config.toml"); + +/** Minimal TOML parser — handles key = value lines. */ +function parseToml(content: string): Record { + const result: Record = {}; + for (const raw of content.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith("[")) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let val = line.slice(eq + 1).trim(); + const commentIdx = val.indexOf(" #"); + if (commentIdx > 0) val = val.slice(0, commentIdx).trim(); + if (val === "true") result[key] = true; + else if (val === "false") result[key] = false; + else if (/^-?\d+$/.test(val)) result[key] = parseInt(val, 10); + else if (/^-?\d+\.\d+$/.test(val)) result[key] = parseFloat(val); + else result[key] = val.replace(/^["']|["']$/g, ""); + } + return result; +} + +function loadTomlFile(path: string): Record { + try { return parseToml(readFileSync(path, "utf-8")); } + catch { return {}; } +} + +/** Type-safe merge: only applies values that match the type in DEFAULTS. */ +function applySource(target: AineConfig, source: Record) { + for (const key of Object.keys(DEFAULTS) as (keyof AineConfig)[]) { + if (key in source && typeof source[key] === typeof DEFAULTS[key]) { + (target as any)[key] = source[key]; + } + } +} + +/** + * Load merged config: defaults ← global ← per-repo. + * CLI flags are applied by the caller after this. + */ +export function loadConfig(repoRoot?: string): AineConfig { + const merged = { ...DEFAULTS }; + applySource(merged, loadTomlFile(GLOBAL_CONFIG)); + if (repoRoot) applySource(merged, loadTomlFile(join(repoRoot, ".aine", "config.toml"))); + return merged; +} diff --git a/aine/diff-session.ts b/aine/diff-session.ts new file mode 100644 index 0000000..c643d83 --- /dev/null +++ b/aine/diff-session.ts @@ -0,0 +1,403 @@ +import { + getChangedFiles, + getOldNewContents, + bulkGetAllContents, + contentHash, + getRepoName, + getCurrentBranch, +} from "./git.js"; +import { + SSRWorker, + stripTrailingWS, + type ThemeInit, + type DiffAnnotation, +} from "./ssr-worker-client.js"; +import { LRUCache } from "./lru-cache.js"; +import { ThreadStore } from "./thread-store.js"; +import type { + ChangedFile, + RenderOptions, + SessionEvent, +} from "./types.js"; + +// ── Constants ───────────────────────────────────────────────── + +const DEFAULT_RENDER_OPTIONS: RenderOptions = { + diffStyle: "split", + expandUnchanged: true, + ignoreWhitespace: false, +}; + +const CACHE_MAX_ENTRIES = 500; +const PRELOAD_WORKERS = 2; + +/** Map file extensions → shiki language IDs for syntax highlighting preload. */ +const EXTENSION_TO_LANG: Record = { + ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", + py: "python", rs: "rust", go: "go", rb: "ruby", + java: "java", kt: "kotlin", swift: "swift", c: "c", cpp: "cpp", + h: "c", hpp: "cpp", cs: "csharp", php: "php", + sh: "bash", zsh: "bash", bash: "bash", + json: "json", yaml: "yaml", yml: "yaml", toml: "toml", + md: "markdown", mdx: "mdx", + css: "css", scss: "scss", html: "html", svelte: "svelte", vue: "vue", + sql: "sql", graphql: "graphql", proto: "protobuf", + dockerfile: "dockerfile", tf: "hcl", +}; + +// ── Helpers ─────────────────────────────────────────────────── + +function optionsKey(opts: RenderOptions): string { + return `${opts.diffStyle}:${opts.expandUnchanged}:${opts.ignoreWhitespace}`; +} + +function applyWhitespace( + oldContent: string | null, + newContent: string | null, + ignoreWhitespace: boolean, +): { oldContent: string | null; newContent: string | null } { + if (!ignoreWhitespace) return { oldContent, newContent }; + return { + oldContent: stripTrailingWS(oldContent), + newContent: stripTrailingWS(newContent), + }; +} + +/** + * Manages diff cache, threads, and background preloading for one Aine session. + * + * Architecture: + * - **userWorker**: dedicated SSR worker for user-initiated renders (never blocked) + * - **preloadWorkers[]**: pool of SSR workers for background preloading + * - **threadStore**: in-memory thread/comment store (delegated) + * - **eventListeners**: SSE subscribers for live UI updates + * + * Preload uses `git cat-file --batch` to bulk-read all file contents (~30× faster + * than sequential `git show`), then distributes SSR rendering across the worker pool. + */ +export class DiffSession { + private cache = new LRUCache(CACHE_MAX_ENTRIES); + private userWorker = new SSRWorker(); + private preloadWorkers: SSRWorker[] = []; + private aborted = false; + private filesCache: ChangedFile[] | null = null; + renderOptions: RenderOptions; + + private knownHashes = new Map(); + private contentsCache = new Map(); + private filesByPath = new Map(); + + // ── Threads (delegated to ThreadStore) ────────────────────── + + private threadStore: ThreadStore; + + // ── Event system (SSE) ──────────────────────────────────────── + + private eventListeners = new Set<(event: SessionEvent) => void>(); + + /** Cached repo metadata. */ + repoName: string; + branch: string; + + /** Current diff args (can be changed via reload). */ + private diffArgs: string[]; + private cwd: string; + private description: string = ""; + + /** Hint: which file the user is currently viewing. */ + currentFile: string | null = null; + + constructor(diffArgs: string[], cwd: string, themeInit?: ThemeInit) { + this.diffArgs = diffArgs; + this.cwd = cwd; + this.renderOptions = { ...DEFAULT_RENDER_OPTIONS }; + this.repoName = getRepoName(cwd); + this.branch = getCurrentBranch(cwd); + + // Thread store emits events through our event system and invalidates cache + this.threadStore = new ThreadStore((event) => { + // Invalidate cached HTML when threads change on a file + if (event.type === "thread-created" || event.type === "thread-updated") { + this.invalidateFile(event.thread.file); + } else if (event.type === "thread-deleted") { + // For delete we need to find the file from the event — but id-only. + // ThreadStore already fired this after removing. We clear all thread-related + // caches on the next render anyway. + } + this.emitEvent(event); + }); + + // Set theme on all workers before starting them + if (themeInit) { + this.userWorker.setTheme(themeInit); + } + for (let i = 0; i < PRELOAD_WORKERS; i++) { + const w = new SSRWorker(); + if (themeInit) w.setTheme(themeInit); + this.preloadWorkers.push(w); + } + + this.userWorker.ensure().catch(() => {}); + for (const w of this.preloadWorkers) w.ensure().catch(() => {}); + } + + // ── File list ───────────────────────────────────────────────── + + getFiles(): ChangedFile[] { + if (!this.filesCache) { + this.filesCache = getChangedFiles(this.diffArgs, this.cwd); + this.filesByPath.clear(); + for (const f of this.filesCache) this.filesByPath.set(f.path, f); + } + return this.filesCache; + } + + getFileByPath(path: string): ChangedFile | undefined { + this.getFiles(); + return this.filesByPath.get(path); + } + + // ── Preload status ──────────────────────────────────────────── + + private _preloadStatusCache: { cached: number; total: number; preloading: boolean } | null = null; + private _preloadStatusTs = 0; + + get cachedCount(): number { + return this.preloadStatus().cached; + } + + private cacheKey(hash: string): string { + return hash + "\0" + optionsKey(this.renderOptions); + } + + private cacheSet(key: string, html: string): void { + this.cache.set(key, html); + this._preloadStatusCache = null; + } + + // ── Annotations from threads ──────────────────────────────── + + private getAnnotationsForFile(filePath: string): DiffAnnotation[] { + return this.threadStore.forFile(filePath).map(thread => ({ + side: (thread.side === "old" ? "deletions" : "additions") as "deletions" | "additions", + lineNumber: thread.endLine, + })); + } + + /** Invalidate cached HTML for a specific file (e.g. after thread change). */ + private invalidateFile(filePath: string) { + const hash = this.knownHashes.get(filePath); + if (hash) { + this.cache.deleteByPrefix(hash + "\0"); + } + this._preloadStatusCache = null; + } + + // ── User-facing render ──────────────────────────────────────── + + private getContents(file: ChangedFile): { oldContent: string | null; newContent: string | null } { + const cached = this.contentsCache.get(file.path); + if (cached) return cached; + return getOldNewContents(file, this.diffArgs, this.cwd); + } + + async getDiffHTMLForUser(file: ChangedFile): Promise { + this.currentFile = file.path; + let { oldContent, newContent } = this.getContents(file); + ({ oldContent, newContent } = applyWhitespace( + oldContent, newContent, this.renderOptions.ignoreWhitespace, + )); + const hash = contentHash(oldContent, newContent); + this.knownHashes.set(file.path, hash); + + const annotations = this.getAnnotationsForFile(file.path); + const annKey = annotations.length > 0 + ? ":" + annotations.map(a => `${a.side}:${a.lineNumber}`).sort().join(",") + : ""; + const key = this.cacheKey(hash) + annKey; + const cached = this.cache.get(key); + if (cached) return cached; + + const html = await this.userWorker.render( + file, oldContent, newContent, this.renderOptions, annotations, + ); + this.cacheSet(key, html); + return html; + } + + // ── Background preload ──────────────────────────────────────── + + async preloadAll() { + this.aborted = false; + const allFiles = this.getFiles(); + if (allFiles.length === 0) return; + + let files = allFiles; + if (this.currentFile) { + const idx = allFiles.findIndex((f) => f.path === this.currentFile); + if (idx > 0) { + files = [...allFiles.slice(idx), ...allFiles.slice(0, idx)]; + } + } + + const bulkContents = await bulkGetAllContents(files, this.diffArgs, this.cwd); + if (this.aborted) return; + + // Preload syntax highlighting for all file extensions + const extensions = new Set(); + for (const file of files) { + const ext = file.path.split(".").pop()?.toLowerCase(); + if (ext) extensions.add(ext); + } + const langs = [...extensions] + .map((ext) => EXTENSION_TO_LANG[ext]) + .filter(Boolean); + if (langs.length > 0) { + for (const w of this.preloadWorkers) w.preloadLangs(langs); + this.userWorker.preloadLangs(langs); + } + + type WorkItem = { + file: ChangedFile; + oldContent: string | null; + newContent: string | null; + key: string; + annotations: DiffAnnotation[]; + }; + const queue: WorkItem[] = []; + + for (const file of files) { + const raw = bulkContents.get(file.path); + if (!raw) continue; + this.contentsCache.set(file.path, raw); + + let { oldContent, newContent } = raw; + ({ oldContent, newContent } = applyWhitespace( + oldContent, newContent, this.renderOptions.ignoreWhitespace, + )); + const hash = contentHash(oldContent, newContent); + this.knownHashes.set(file.path, hash); + + const annotations = this.getAnnotationsForFile(file.path); + const annKey = annotations.length > 0 + ? ":" + annotations.map(a => `${a.side}:${a.lineNumber}`).sort().join(",") + : ""; + const key = this.cacheKey(hash) + annKey; + if (this.cache.has(key)) continue; + queue.push({ file, oldContent, newContent, key, annotations }); + } + + if (queue.length === 0 || this.aborted) return; + + let nextIdx = 0; + const workerLoop = async (worker: SSRWorker) => { + while (!this.aborted) { + const idx = nextIdx++; + if (idx >= queue.length) break; + const item = queue[idx]; + try { + const html = await worker.render( + item.file, item.oldContent, item.newContent, this.renderOptions, item.annotations, + ); + if (!this.aborted) { + this.cacheSet(item.key, html); + } + } catch {} + } + }; + + await Promise.all(this.preloadWorkers.map((w) => workerLoop(w))); + } + + // ── Options / refresh / reload ──────────────────────────────── + + setOptions(opts: Partial) { + const prev = optionsKey(this.renderOptions); + Object.assign(this.renderOptions, opts); + if (optionsKey(this.renderOptions) !== prev) { + this.aborted = true; + setTimeout(() => this.preloadAll().catch(() => {}), 50); + } + } + + /** Signal that files on disk changed — UI shows a banner instead of auto-refreshing. */ + markStale() { + this.emitEvent({ type: "stale" }); + } + + refresh() { + this.aborted = true; + this.filesCache = null; + this.filesByPath.clear(); + this.knownHashes.clear(); + this.contentsCache.clear(); + this.repoName = getRepoName(this.cwd); + this.branch = getCurrentBranch(this.cwd); + setTimeout(() => this.preloadAll().catch(() => {}), 50); + this.emitEvent({ type: "refresh" }); + } + + /** Swap what this session is showing without restarting the server. */ + reloadWith(newDiffArgs: string[], newDescription: string) { + this.diffArgs = newDiffArgs; + this.description = newDescription; + this.refresh(); + this.emitEvent({ type: "reload", diffArgs: newDiffArgs, description: newDescription }); + } + + preloadStatus(): { cached: number; total: number; preloading: boolean } { + const now = Date.now(); + if (this._preloadStatusCache && now - this._preloadStatusTs < 100) { + return this._preloadStatusCache; + } + const files = this.getFiles(); + const optsKey = optionsKey(this.renderOptions); + let cached = 0; + for (const file of files) { + const hash = this.knownHashes.get(file.path); + if (hash && this.cache.has(hash + "\0" + optsKey)) cached++; + } + this._preloadStatusCache = { + cached, + total: files.length, + preloading: !this.aborted && cached < files.length, + }; + this._preloadStatusTs = now; + return this._preloadStatusCache; + } + + // ── Thread API (delegates to ThreadStore) ───────────────────── + + createThread(data: Parameters[0]) { return this.threadStore.create(data); } + addReply(threadId: string, body: string, author?: Parameters[2]) { return this.threadStore.addReply(threadId, body, author); } + updateThreadStatus(id: string, status: Parameters[1]) { return this.threadStore.updateStatus(id, status); } + editThread(id: string, body: string) { return this.threadStore.edit(id, body); } + deleteThread(id: string) { return this.threadStore.delete(id); } + getThreads(filter?: Parameters[0]) { return this.threadStore.list(filter); } + getThread(id: string) { return this.threadStore.get(id); } + getThreadCount() { return this.threadStore.count; } + + // ── Event system ────────────────────────────────────────────── + + addEventListener(handler: (event: SessionEvent) => void) { + this.eventListeners.add(handler); + } + + removeEventListener(handler: (event: SessionEvent) => void) { + this.eventListeners.delete(handler); + } + + emitEvent(event: SessionEvent) { + for (const handler of this.eventListeners) { + try { handler(event); } catch {} + } + } + + // ── Cleanup ─────────────────────────────────────────────────── + + destroy() { + this.aborted = true; + this.userWorker.kill(); + for (const w of this.preloadWorkers) w.kill(); + } +} diff --git a/aine/dist/index.html b/aine/dist/index.html new file mode 100644 index 0000000..ff24c80 --- /dev/null +++ b/aine/dist/index.html @@ -0,0 +1,77 @@ + + + + + + aine + + + + +
+ + diff --git a/aine/git.ts b/aine/git.ts new file mode 100644 index 0000000..3632743 --- /dev/null +++ b/aine/git.ts @@ -0,0 +1,363 @@ +import { execFileSync, spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { FileStatus, ChangedFile } from "./types.js"; + + + +const MAX_BUF = 50 * 1024 * 1024; + +function git(args: string[], cwd: string): string { + return execFileSync("git", args, { + cwd, encoding: "utf8", maxBuffer: MAX_BUF, stdio: ["pipe", "pipe", "pipe"], + }).trim(); +} + +function gitSafe(args: string[], cwd: string): string { + try { return git(args, cwd); } catch { return ""; } +} + +export function isGitRepo(cwd: string): boolean { + try { git(["rev-parse", "--is-inside-work-tree"], cwd); return true; } catch { return false; } +} + +export function getRepoRoot(cwd: string): string { + return git(["rev-parse", "--show-toplevel"], cwd); +} + +export function getRepoName(cwd: string): string { + return getRepoRoot(cwd).split("/").pop() || "unknown"; +} + +export function getCurrentBranch(cwd: string): string { + try { return git(["branch", "--show-current"], cwd); } catch { return git(["rev-parse", "--short", "HEAD"], cwd); } +} + +export function isValidGitRef(ref: string, cwd: string): boolean { + if (ref.includes("..")) return ref.split("..").every(p => p === "" || isValidGitRef(p, cwd)); + try { git(["rev-parse", "--verify", ref], cwd); return true; } catch { return false; } +} + +/** + * Parse CLI/command refs into diffArgs + description. + * Returns `{ error }` if any ref is invalid. + */ +export function parseRefs( + refs: string[], + cwd: string, +): { diffArgs: string[]; description: string } | { error: string } { + for (const ref of refs) { + if (!isValidGitRef(ref, cwd)) return { error: ref }; + } + + if (refs.length === 1) { + return { + diffArgs: [refs[0]], + description: refs[0].includes("..") ? refs[0] : "Changes from " + refs[0], + }; + } + if (refs.length === 2) { + return { + diffArgs: [refs[0] + ".." + refs[1]], + description: refs[0] + ".." + refs[1], + }; + } + return { diffArgs: [], description: "Working tree changes" }; +} + +const STATUS_MAP: Record = { + A: "added", D: "deleted", M: "modified", R: "renamed", C: "copied", T: "modified", +}; + +function parseNumstat(raw: string): Map { + const m = new Map(); + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + const [a, d, ...rest] = line.split("\t"); + const path = rest.join("\t"); + if (!path) continue; + m.set(path, { add: a === "-" ? 0 : +a, del: d === "-" ? 0 : +d }); + } + return m; +} + +export function getChangedFiles(diffArgs: string[], cwd: string): ChangedFile[] { + const files: ChangedFile[] = []; + + if (diffArgs.length === 0) { + const staged = gitSafe(["diff", "--cached", "--name-status", "--no-renames"], cwd); + const unstaged = gitSafe(["diff", "--name-status", "--no-renames"], cwd); + const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd); + const stagedNum = parseNumstat(gitSafe(["diff", "--cached", "--numstat", "--no-renames"], cwd)); + const unstagedNum = parseNumstat(gitSafe(["diff", "--numstat", "--no-renames"], cwd)); + + const seen = new Set(); + for (const line of [...staged.split("\n"), ...unstaged.split("\n")]) { + if (!line.trim()) continue; + const [code, ...rest] = line.split("\t"); + const path = rest[rest.length - 1]; + if (!path || seen.has(path)) continue; + seen.add(path); + const sn = stagedNum.get(path); + const un = unstagedNum.get(path); + files.push({ + path, + status: STATUS_MAP[code[0]] || "modified", + additions: (sn?.add || 0) + (un?.add || 0), + deletions: (sn?.del || 0) + (un?.del || 0), + }); + } + + for (const p of untracked.split("\n")) { + if (!p.trim() || seen.has(p)) continue; + seen.add(p); + let lines = 0; + try { + const content = readFileSync(join(cwd, p), "utf-8"); + lines = content.split("\n").length; + if (content.endsWith("\n")) lines--; + } catch {} + files.push({ path: p, status: "added", untracked: true, additions: lines, deletions: 0 }); + } + } else { + const out = gitSafe(["diff", "--name-status", ...diffArgs], cwd); + const numstat = parseNumstat(gitSafe(["diff", "--numstat", ...diffArgs], cwd)); + + for (const line of out.split("\n")) { + if (!line.trim()) continue; + const [code, ...rest] = line.split("\t"); + const path = rest[rest.length - 1]; + const oldPath = rest.length > 1 ? rest[0] : undefined; + if (!path) continue; + const ns = numstat.get(path); + files.push({ + path, oldPath, + status: STATUS_MAP[code[0]] || "modified", + additions: ns?.add || 0, + deletions: ns?.del || 0, + }); + } + } + + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +export function getFileAtRef(ref: string, filePath: string, cwd: string): string | null { + try { + return execFileSync("git", ["show", `${ref}:${filePath}`], { + cwd, encoding: "utf8", maxBuffer: MAX_BUF, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch { + return null; + } +} + +export function readWorkingFile(filePath: string, cwd: string): string | null { + try { + return readFileSync(join(cwd, filePath), "utf-8"); + } catch { + return null; + } +} + +/** Parse diffArgs into old/new refs. null newRef means working tree. */ +export function parseDiffRefs(diffArgs: string[]): { oldRef: string; newRef: string | null } { + if (diffArgs.length === 0) return { oldRef: "HEAD", newRef: null }; + const arg = diffArgs[0]; + if (arg.includes("..")) { + const [a, b] = arg.split(".."); + return { oldRef: a || "HEAD", newRef: b || "HEAD" }; + } + return { oldRef: arg, newRef: null }; +} + +export function getOldNewContents( + file: ChangedFile, + diffArgs: string[], + cwd: string, +): { oldContent: string | null; newContent: string | null } { + const { oldRef, newRef } = parseDiffRefs(diffArgs); + + let oldContent: string | null = null; + let newContent: string | null = null; + + if (file.status !== "added" || !file.untracked) { + oldContent = getFileAtRef(oldRef, file.oldPath || file.path, cwd); + } + + if (file.status !== "deleted") { + newContent = newRef + ? getFileAtRef(newRef, file.path, cwd) + : readWorkingFile(file.path, cwd); + } + + return { oldContent, newContent }; +} + +/** Fast hash of old+new content pair, used as cache key. Uses Bun.hash (wyhash). */ +export function contentHash( + oldContent: string | null, + newContent: string | null, +): string { + // Hash each part separately to avoid concatenating large strings. + // Mix h1 into h2's seed so collisions require both parts to collide simultaneously. + const h1 = Bun.hash(oldContent ?? "\0NULL\0"); + const h2 = Bun.hash(newContent ?? "\0NULL\0", Number(h1 & 0xFFFFFFFFn)); + return h1.toString(36) + "_" + h2.toString(36); +} + +/** + * Bulk-read file contents at a given ref using `git cat-file --batch`. + * Returns a Map from "ref:path" → content string (missing entries = file not found). + * ~30× faster than sequential `git show` for many files. + */ +export function bulkGetFilesAtRef( + ref: string, + paths: string[], + cwd: string, +): Promise> { + return new Promise((resolve) => { + if (paths.length === 0) { + resolve(new Map()); + return; + } + + const proc = spawn("git", ["cat-file", "--batch"], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + + const results = new Map(); + let buf = Buffer.alloc(0); + let bufOffset = 0; // track consumed bytes to avoid repeated subarray + let pendingIdx = 0; + + // Each response from cat-file --batch is: + // \n\n (for found objects) + // missing\n (for missing objects) + const processBuffer = () => { + while (pendingIdx < paths.length) { + // Need at least one line (header) + const nlIdx = buf.indexOf(10, bufOffset); // \n + if (nlIdx === -1) break; + + const headerLine = buf.subarray(bufOffset, nlIdx).toString("utf8"); + const query = `${ref}:${paths[pendingIdx]}`; + + if (headerLine.endsWith(" missing")) { + results.set(query, null); + bufOffset = nlIdx + 1; + pendingIdx++; + continue; + } + + // Parse: " blob " + const sizeMatch = headerLine.match(/\s(\d+)$/); + if (!sizeMatch) { + // Unexpected format, skip + results.set(query, null); + bufOffset = nlIdx + 1; + pendingIdx++; + continue; + } + + const size = parseInt(sizeMatch[1], 10); + const contentStart = nlIdx + 1; + const contentEnd = contentStart + size; + // Need size bytes + trailing \n + if (buf.length < contentEnd + 1) break; + + results.set(query, buf.subarray(contentStart, contentEnd).toString("utf8")); + bufOffset = contentEnd + 1; + pendingIdx++; + } + // Compact buffer when we've consumed a lot + if (bufOffset > 65536) { + buf = buf.subarray(bufOffset); + bufOffset = 0; + } + }; + + proc.stdout.on("data", (chunk: Buffer) => { + buf = bufOffset > 0 + ? Buffer.concat([buf.subarray(bufOffset), chunk]) + : Buffer.concat([buf, chunk]); + bufOffset = 0; + processBuffer(); + }); + + proc.on("close", () => { + processBuffer(); + // Mark any remaining as null + for (let i = pendingIdx; i < paths.length; i++) { + results.set(`${ref}:${paths[i]}`, null); + } + resolve(results); + }); + + proc.stderr.on("data", () => {}); + + for (const p of paths) { + proc.stdin.write(`${ref}:${p}\n`); + } + proc.stdin.end(); + }); +} + +/** + * Bulk-read old+new contents for all changed files. + * Uses `git cat-file --batch` for ref-based reads (30× faster than sequential git show). + */ +export async function bulkGetAllContents( + files: ChangedFile[], + diffArgs: string[], + cwd: string, +): Promise> { + const { oldRef, newRef } = parseDiffRefs(diffArgs); + + // Collect paths to read from each ref + const oldPaths: string[] = []; + const newRefPaths: string[] = []; + for (const file of files) { + if (file.status !== "added" || !file.untracked) { + oldPaths.push(file.oldPath || file.path); + } + if (file.status !== "deleted" && newRef) { + newRefPaths.push(file.path); + } + } + + // Bulk-read from git refs in parallel + const [oldResults, newRefResults] = await Promise.all([ + bulkGetFilesAtRef(oldRef, oldPaths, cwd), + newRef ? bulkGetFilesAtRef(newRef, newRefPaths, cwd) : Promise.resolve(new Map()), + ]); + + // Assemble results + const result = new Map(); + for (const file of files) { + let oldContent: string | null = null; + let newContent: string | null = null; + + if (file.status !== "added" || !file.untracked) { + const key = `${oldRef}:${file.oldPath || file.path}`; + oldContent = oldResults.get(key) ?? null; + } + + if (file.status !== "deleted") { + if (newRef) { + const key = `${newRef}:${file.path}`; + newContent = newRefResults.get(key) ?? null; + } else { + newContent = readWorkingFile(file.path, cwd); + } + } + + result.set(file.path, { oldContent, newContent }); + } + + return result; +} + + diff --git a/aine/lru-cache.ts b/aine/lru-cache.ts new file mode 100644 index 0000000..368ca9b --- /dev/null +++ b/aine/lru-cache.ts @@ -0,0 +1,57 @@ +/** + * Simple LRU cache with a max-entry limit. + * Uses a Map (insertion-ordered) — on get/set the entry is + * moved to the end so the oldest entry is always first. + */ +export class LRUCache { + private map = new Map(); + + constructor(private maxSize: number) {} + + get(key: K): V | undefined { + const val = this.map.get(key); + if (val === undefined) return undefined; + // Move to end (most recently used) + this.map.delete(key); + this.map.set(key, val); + return val; + } + + has(key: K): boolean { + return this.map.has(key); + } + + set(key: K, val: V): void { + if (this.map.has(key)) this.map.delete(key); + this.map.set(key, val); + while (this.map.size > this.maxSize) { + // Delete oldest (first) entry + const first = this.map.keys().next().value!; + this.map.delete(first); + } + } + + delete(key: K): boolean { + return this.map.delete(key); + } + + /** Delete all entries whose key starts with the given prefix (string keys only). */ + deleteByPrefix(prefix: string): number { + let count = 0; + for (const key of [...this.map.keys()]) { + if (typeof key === "string" && key.startsWith(prefix)) { + this.map.delete(key); + count++; + } + } + return count; + } + + get size(): number { + return this.map.size; + } + + clear(): void { + this.map.clear(); + } +} diff --git a/aine/notes/diffity-analysis.md b/aine/notes/diffity-analysis.md new file mode 100644 index 0000000..77dfe7d --- /dev/null +++ b/aine/notes/diffity-analysis.md @@ -0,0 +1,127 @@ +# Diffity vs. Aine – Analýza + +> Datum: 2026-03-26 +> Zdroj: https://github.com/kamranahmedse/diffity + +## Co aine už má (a dělá dobře) + +- ✅ GitHub-style diff viewer (split/unified) s SSR přes `@pierre/diffs` +- ✅ Session management (multi-repo, port discovery, PID tracking) +- ✅ Komentáře s severity (`must-fix`, `suggestion`, `nit`) +- ✅ CLI pro agenty (`aine session comment add/list/resolve/rm/clear`) +- ✅ SSE live updates (komentáře, navigace, reload) +- ✅ Background preloading s worker poolem + `git cat-file --batch` +- ✅ Theme system (shiki-based, TOML config, per-repo override) +- ✅ Keyboard shortcuts (vim-style) +- ✅ File watcher s auto-refresh +- ✅ TOML config (globální + per-repo) + +--- + +## Features z diffity, které by se hodily do aine + +### 1. 🔥 Guided Code Tours (nejvyšší přidaná hodnota) + +Diffity umí `diffity-tour` — agent projde codebase a vytvoří narativní walkthrough s highlighted code regiony. Aine nic takového nemá. Využití: +- Onboarding nových devů +- Vysvětlení architektury +- Code review s kontextem + +**Jak na to:** Přidat tours model (SQLite/JSON), API routes (`/api/tours`), UI panel se step navigací, a `aine session tour` CLI subcommand. + +### 2. 🔥 Thread model místo flat comments + +Diffity má plnohodnotné **thread** systém: +- Komentáře mají **replies** (vlákna konverzací) +- **Multi-line selection** (startLine + endLine) — aine má jen single line +- **Anchor content** — ukládá obsah řádku pro drift detection +- Status: `open` / `resolved` / `dismissed` (aine nemá `dismissed`) +- **General comments** (diff-level, bez souboru) + +**Aine teď:** Flat `Comment` s jedním `line`, bez reply chain, bez anchor content. + +### 3. 🔥 `aine agent` CLI (dedikovaný agent subcommand) + +Diffity má `diffity agent diff/comment/resolve/dismiss/reply/tour-start/tour-step/tour-done` — čistý CLI interface pro agenty. Aine to dělá přes `aine session comment add`, což je verbose a méně přehledné. + +**Lépe:** Přidat `aine agent` s krátkými příkazy: +``` +aine agent diff +aine agent comment --file X --line 5 --body "..." +aine agent resolve +aine agent reply --body "..." +aine agent tour-start --topic "..." +``` + +### 4. ⚡ SQLite persistence (better-sqlite3) + +Diffity ukládá review sessions, threads, tours do SQLite s WAL mode. Aine drží vše **in-memory** v `Map` — ztráta dat po restartu. + +**Výhoda SQLite:** Přetrvává restart, lepší querying, funguje i pro tours. + +Diffity schema: +```sql +CREATE TABLE review_sessions (id, ref, head_hash, created_at); +CREATE TABLE comment_threads (id, session_id, file_path, side, start_line, end_line, status, anchor_content, ...); +CREATE TABLE comments (id, thread_id, author_name, author_type, body, created_at); +CREATE TABLE tours (id, session_id, topic, body, status, created_at); +CREATE TABLE tour_steps (id, tour_id, sort_order, file_path, start_line, end_line, body, annotation, created_at); +``` + +### 5. ⚡ Stale diff detection + banner + +Diffity polluje fingerprint diffu každé 3s a ukáže banner "diff is stale, refresh" když se soubory změní. Aine má file watcher ale auto-refreshuje — nemá UI indikátor, že se změnil podkladový kód. + +### 6. ⚡ Context expansion (expandable gaps) + +Diffity umí **expand context** around hunks — klikneš na gap a zobrazí dalších 20 řádků. Aine má jen toggle `expandUnchanged` (vše nebo nic). + +Klíčový soubor: `packages/ui/src/lib/context-expansion.ts` — počítá gaps mezi hunky a umožňuje inkrementální expand po 20 řádcích. + +### 7. ⚡ File tree browser (`diffity tree`) + +Diffity umí `diffity tree` — procházení celého repo, ne jen changed files. Komentáře na jakýkoli soubor. Aine zobrazuje jen soubory v diffu. + +### 8. 💡 GitHub PR integrace + +Diffity umí: +- Stáhnout PR přes `gh` CLI +- Push/pull komentářů na GitHub +- Obousměrná sync review komentářů + +Klíčový soubor: `packages/github/src/pr.ts` — `pullComments()`, `pushComments()`, `getFiles()`. + +### 9. 💡 Word-level diff highlighting + +Diffity má vlastní `@diffity/parser` s **word-diff** — zvýrazní přesně která slova se změnila v řádku, ne jen celý řádek. + +### 10. 💡 Line selection (drag-to-select) + +Diffity umí kliknout a táhnout pro výběr **rozsahu řádků** pro komentář (mouse drag s anchor line tracking). Aine má jen single-line. + +Klíčový soubor: `packages/ui/src/hooks/use-line-selection.ts`. + +--- + +## Co aine dělá LÍP než diffity + +| Oblast | Aine | Diffity | +|--------|------|---------| +| **Tech stack** | Svelte 5 + Bun (lehký, single binary) | React + Node + monorepo (těžší) | +| **SSR rendering** | Worker pool s `@pierre/diffs` | Klientský rendering | +| **Preloading** | `git cat-file --batch` bulk read + background SSR | On-demand | +| **Single file deploy** | `vite-plugin-singlefile` — vše v jednom HTML | Separate build | +| **Config** | TOML config (global + per-repo) | Žádný config soubor | +| **Licence** | MIT | PolyForm Shield (omezující!) | +| **Závislosti** | Minimal (Bun built-in) | better-sqlite3, React, commander, picocolors... | + +--- + +## Prioritní doporučení (co implementovat) + +1. **Thread model s replies + multi-line selection** — největší UX improvement +2. **Context expansion** (kliknutí na gap pro zobrazení okolních řádků) +3. **Guided code tours** — unikátní feature, obrovská přidaná hodnota +4. **SQLite persistence** — spolehlivost dat +5. **`aine agent` CLI** — čistší API pro agenty +6. **Stale diff banner** — lepší UX než silent auto-refresh diff --git a/aine/notes/plan.md b/aine/notes/plan.md new file mode 100644 index 0000000..cc92902 --- /dev/null +++ b/aine/notes/plan.md @@ -0,0 +1,88 @@ +# Aine Refactoring Plan + +**Cíl:** Maximální udržitelnost a budoucí rozšiřitelnost kódu. + +## Analýza + +| Soubor | Řádky | Problém | +|--------|-------|---------| +| `DiffView.svelte` | 581 | God component — cache, hunks, komentáře, gutter eventy | +| `diff-session.ts` | 504 | Míchá diff cache, thread CRUD, event system, preload | +| `CommentsPanel.svelte` | 482 | Scroll sync polluje `setInterval(500ms)` | +| `Sidebar.svelte` | 386 | OK, ale tree logika patří do lib/ | +| `git.ts` | 363 | Mrtvé re-exporty typů | +| `routes.ts` | 340 | OK | +| `session-cli.ts` | 299 | OK | +| `cli.ts` | 279 | OK | + +## 1. Odstranit mrtvý kód a zbytečné re-exporty + +**Problém:** Typy se re-exportují přes 3 soubory místo přímého importu z `types.ts`. + +- `diff-session.ts` — `export type { RenderOptions, Severity, ... } from "./types.js"` → nikdo neimportuje typy z diff-session +- `ssr-worker-client.ts` — `export type { RenderOptions } from "./types.js"` → nepoužito +- `git.ts` — `export type { FileStatus, ChangedFile } from "./types.js"` → nepoužito +- `theme.ts` — `export type { ThemeColors as AineUIColors }` → alias nikdy nepoužit + +## 2. Extrahovat ThreadStore z DiffSession + +**Problém:** `DiffSession` (504 řádků) dělá příliš mnoho — diff cache + thread CRUD + eventy + preload. + +**Řešení:** Nový `thread-store.ts` (~120 řádků): +- `createThread()`, `addReply()`, `updateThreadStatus()`, `editThread()`, `deleteThread()` +- `getThreads()`, `getThread()`, `findThread()`, `getThreadCount()` +- Vlastní event emitter pro thread změny +- DiffSession deleguje na ThreadStore + +## 3. Extrahovat CommentInput z DiffView + +**Problém:** `DiffView.svelte` (581 řádků) — comment input logika zabírá ~150 řádků. + +**Řešení:** Nový `CommentInput.svelte`: +- Comment form (textarea, submit, cancel) +- Positioning logic (`positionInputAtLine`) +- Edit mode handling +- DiffView jen předává props + +## 4. Extrahovat tree-building logiku ze Sidebar + +**Problém:** Sidebar obsahuje ~100 řádků pure tree-building logiky vmíchaného s UI. + +**Řešení:** Nový `src/lib/file-tree.ts`: +- `buildTree()`, `squash()`, `collectVisibleItems()`, `collectAllFolderKeys()` +- `findFolderForPath()`, `findCollapsedFolderForPath()` +- Sidebar se zjednoduší na čistý UI + +## 5. Extrahovat EXTENSION_TO_LANG constant + +**Problém:** Hardcoded mapping v preloadAll() uprostřed business logiky. + +**Řešení:** Přesunout do `types.ts` nebo nový `constants.ts` — sdílené mezi diff-session a ssr-worker. + +## 6. Vyčistit CommentsPanel scroll sync + +**Problém:** `setInterval(500ms)` polling pro detekci scroll elementu. + +**Řešení:** Předat scrollEl jako prop z App.svelte (je vždy dostupný) nebo použít `$effect` na `selectedPath`. + +## 7. Zjednodušit DiffView client cache + +**Problém:** DiffView má vlastní inline LRU cache (~30 řádků) duplikující backend pattern. + +**Řešení:** Reuse `LRUCache` class — přesunout do `src/lib/lru-cache.ts` (symlink/copy pro frontend). + +## Pořadí provedení + +1. Mrtvý kód (bezpečné, žádné dependencies) +2. `thread-store.ts` extrakce +3. `src/lib/file-tree.ts` extrakce +4. `CommentInput.svelte` extrakce +5. CommentsPanel scroll sync cleanup +6. Constants extrakce +7. Client LRU cache reuse + +## Rizika + +- Testy musí projít po každém kroku +- Svelte 5 `bind:this` a `$props()` mají specifické patterny +- SSR worker komunikace nesmí být narušena diff --git a/aine/notes/review-workflow-brainstorm.md b/aine/notes/review-workflow-brainstorm.md new file mode 100644 index 0000000..45cbfa2 --- /dev/null +++ b/aine/notes/review-workflow-brainstorm.md @@ -0,0 +1,503 @@ +# Aine: AI Code Review Workflow — Brainstorm + +## Současný stav + +Aine je diff viewer s inline komentáři, který AI agent ovládá přes CLI (`aine session *`). + +**Umí:** diff rendering, thready s severity/status, file watching, preload cache, themes, vim klávesy, SSE live updates. + +**Problém:** Je to viewer, ne review tool. Chybí workflow — kdo co dělá, kdy je hotovo, jak se feedback vrací agentovi. + +--- + +## Cílová vize + +Aine = **review loop mezi člověkem a AI agentem**. + +``` +Agent pracuje → checkpoint → AINE review → feedback → agent fixuje → re-review → approve → done +``` + +Ne jen "podívej se na diff", ale celý řízený proces s jasným stavem a výstupem. + +--- + +## Klíčové features (seřazeno podle priority) + +### P0: Approval workflow + feedback loop + +**Problém:** Dnes agent udělá změny, uživatel se podívá, ale není formální "approve / request changes" krok. Agent neví, jestli je review hotové. + +**Návrh:** + +Review session má globální stav: +``` +pending → in_review → changes_requested → approved / rejected +``` + +UI dole (nebo v toolbaru): +- **"Request Changes"** — uživatel napíše souhrnný feedback, stav → `changes_requested` +- **"Approve"** — stav → `approved` +- **"Reject"** — stav → `rejected` (zahoď všechno) + +Agent se dozví přes: +- `aine session status --repo .` → vrátí stav + feedback +- SSE event `review-verdict` → agent čeká na verdict +- Exit code? (aine blokuje dokud uživatel nerozhodne?) + +**Otevřené otázky:** +- Má aine blokovat agenta (agent spustí `aine review` a čeká na výsledek)? +- Nebo je to async (agent se pravidelně ptá)? +- Nebo obojí (CLI s `--wait` flag)? + +--- + +### P1: File-level review status + +**Problém:** Při 40 souborech nevíš, které jsi reviewoval, které jsou OK, které potřebují práci. + +**Návrh:** + +Každý soubor má status: +``` +unreviewed → viewed → approved | needs_work +``` + +Sidebar ukazuje stav: +``` +☐ auth.ts (unreviewed) +👁 db.ts (viewed — otevřel ale neoznačil) +✅ config.ts (approved) +⚠ api.ts (needs work — má open komentáře) +``` + +Automatické přechody: +- `unreviewed → viewed` — když uživatel soubor otevře a stráví tam >2s (nebo scrollne dolů) +- `viewed → needs_work` — automaticky když přidá open komentář +- `needs_work → approved` — když všechny komentáře resolved + uživatel explicitně approves + +Klávesová zkratka: `a` = approve current file, `x` = mark needs work + +Progress bar v toolbaru: `Review: 7/12 files ████░░░░ 58%` + +--- + +### P2: Review summary / dashboard + +**Problém:** Otevřeš aine a vidíš seznam souborů. Nevíš co se dělo, jaký byl intent, jaký je stav. + +**Návrh:** + +Summary panel (horní část, collapsible, nebo jako první "soubor" v sidebar): + +``` +┌─────────────────────────────────────────────┐ +│ 📋 Review: Fix auth race condition │ +│ │ +│ 12 files changed +340 / -520 │ +│ Review: 3/12 ██░░░░ │ 2 open comments │ +│ │ +│ Agent summary: │ +│ "Refactored auth middleware to use mutex. │ +│ Key changes: auth.ts (new lock), db.ts │ +│ (connection pooling), api.ts (retry logic)"│ +│ │ +│ Checks: ✅ types ✅ lint ⚠ tests (1 fail) │ +└─────────────────────────────────────────────┘ +``` + +Odkud summary pochází: +- Agent poskytne přes CLI: `aine session meta --repo . --summary "..." --task "..."` +- Nebo automaticky z agent session logu +- Check results: agent pošle přes CLI nebo aine sám spustí configured commands + +--- + +### P3: Agent context (per-file reasoning) + +**Problém:** Vidíš CO se změnilo, ale ne PROČ. Agent měl důvod — ale ten je ztracený v session logu. + +**Návrh:** + +Agent může ke každému souboru přidat "change note": +```bash +aine session annotate --repo . --file auth.ts --note "Added mutex lock to prevent concurrent token refresh. Previous implementation could issue duplicate tokens under load." +``` + +V UI se zobrazí jako collapsible banner nad diffem: +``` +┌─ 🤖 Agent note ───────────────────────────────┐ +│ Added mutex lock to prevent concurrent token │ +│ refresh. Previous implementation could issue │ +│ duplicate tokens under load. │ +└────────────────────────────────────────────────┘ +``` + +Alternativa: agent to dá jako první komentář typu "context" (ne review komentář, ale vysvětlení). + +--- + +### P4: Smart file ordering + +**Problém:** Abecední řazení. Config soubory a testy se míchají s core logic. + +**Návrh:** + +Výchozí řazení podle "review importance": +1. Core source files s velkými změnami +2. Core source files s malými změnami +3. Test files +4. Config / generated / lock files + +Heuristika: +- Velikost změny (additions + deletions) +- Typ souboru (`.ts` > `.json` > `.lock`) +- Path pattern (`src/` > `test/` > `config/`) +- Má open komentáře → boost + +Konfigurovatelné přes `.aine.toml` nebo `aine.config.ts`: +```toml +[review.ordering] +boost = ["src/**"] +demote = ["*.lock", "*.generated.*", "test/**"] +``` + +Sidebar grouping: +``` +── Core changes (5) ── + auth.ts +42 -18 + db.ts +15 -8 +── Tests (4) ── + auth.test.ts +30 +── Config (3) ── + package.json +2 -1 +``` + +--- + +### P5: Inline "explain this" + +**Problém:** Stojíš na řádku, nerozumíš proč ho agent změnil. + +**Návrh:** + +Klávesa `?` na řádku (nebo right-click → "Ask agent") → pošle request agentovi: +```bash +# Agent dostane: +aine session event --repo . +# → { type: "explain-request", file: "auth.ts", line: 42, context: "..." } +``` + +Agent odpoví komentářem: +```bash +aine session comment reply --repo . --summary "This line..." --author agent +``` + +V UI: loading indikátor na řádku → agent response se objeví jako inline annotation. + +**Otevřená otázka:** Jak agent ví, že má odpovědět? Push (SSE event) vs. pull (polling)? + +--- + +### P6: Automated pre-review + +**Problém:** Před review bys chtěl vědět jestli kód aspoň projde základními checky. + +**Návrh:** + +Configurable checks v `.aine.toml`: +```toml +[[review.checks]] +name = "TypeScript" +command = "npx tsc --noEmit" + +[[review.checks]] +name = "Lint" +command = "npx eslint ." + +[[review.checks]] +name = "Tests" +command = "npm test" +``` + +Aine spustí checks na pozadí, výsledky zobrazí: +- V summary panelu: ✅/❌ per check +- Inline: pokud check output obsahuje file:line, přiřadit k souboru + +Optional: "AI pre-review" check — spustí configured AI prompt na diff. + +--- + +### P7: Granular revert + +**Problém:** Vidíš problém v jednom souboru/hunku. Chceš vrátit jen to. + +**Návrh:** + +Per-file akce v sidebar: "Revert file" (git checkout -- file) +Per-hunk akce v diff view: "Revert hunk" (git checkout -p equivalent) + +Potvrzovací dialog. Po revertu se diff automaticky refreshne. + +--- + +## Otevřené designové otázky + +1. **Jak se review session vytváří?** + - `aine review` (nový příkaz, implikuje review mode)? + - `aine diff` + přepnutí do review mode? + - Agent vytvoří: `aine session review-request --repo . --summary "..."`? + +2. **Persistance review stanu?** + - Dnes je vše in-memory. Když aine restartneš, ztratíš komentáře. + - Ukládat do `.aine/reviews/` v repo? + - Nebo do global store `~/.aine/`? + +3. **Multi-agent review?** + - Může víc agentů reviewovat stejný changeset? + - Agent A udělá změny → Agent B reviewuje → člověk finálně schválí? + +4. **Integrace s git workflow?** + - Po approve: auto-commit? auto-push? create PR? + - Nebo je to na uživateli? + +5. **Review history?** + - Chceš se vrátit k minulým review sessions? + - "Minule jsem tohle zamítl, agent to fixnul, chci vidět co se změnilo" + +--- + +## Navržená architektura (high level) + +``` +┌────────────┐ CLI ┌──────────────┐ HTTP/SSE ┌──────────┐ +│ AI Agent │ ◄────────────► │ Aine Server │ ◄──────────────► │ Aine UI │ +│ (pi, ...) │ session cmds │ │ │ (browser) │ +└────────────┘ │ - DiffSession│ └──────────┘ + │ - ReviewState│ + │ - Checks │ + └──────────────┘ +``` + +Nové server-side koncepty: +- **ReviewState** — globální stav review (pending/in_review/approved/...) +- **FileReviewStatus** — per-file stav (unreviewed/viewed/approved/needs_work) +- **ReviewMeta** — task description, agent summary, check results +- **ReviewVerdict** — finální rozhodnutí + feedback message + +Nové CLI příkazy: +```bash +aine session meta --repo . --summary "..." --task "..." +aine session annotate --repo . --file auth.ts --note "..." +aine session status --repo . # → { state, feedback, files } +aine session await-verdict --repo . # blocks until user decides +aine session checks --repo . --name "Tests" --status pass|fail --output "..." +``` + +Nové API endpoints: +``` +POST /api/review/verdict { action: "approve"|"request_changes"|"reject", feedback: "..." } +GET /api/review/status → { state, feedback, fileSummary, checks } +PATCH /api/files/:path/status { status: "approved"|"needs_work" } +POST /api/review/meta { summary, task, context } +POST /api/review/checks { name, status, output } +``` + +--- + +## Experimentální / netypické nápady + +### 🎬 Replay mode — přehrání agentova procesu + +Ne jen finální diff, ale timeline celého agentova workflow. Agent udělal 47 kroků — vidíš: +- Krok 12: přečetl auth.ts, rozhodl se refaktorovat +- Krok 15: první pokus, napsal to s Promise.all +- Krok 22: testy spadly, přepsal na mutex +- Krok 31: opravil edge case + +Klikneš na kterýkoliv krok → vidíš diff *v tom momentě*. Jako git bisect, ale na úrovni agentovy práce. Pochopíš PROČ je finální kód takový — vidíš slepé uličky, které agent zkusil a zahodil. Pochopíš reasoning skrz proces, ne jen výsledek. + +Zdroj dat: agent session log (pi sessions mají JSONL s každým krokem). + +--- + +### 🔬 Semantic diff — ne řádky, ale záměr + +Normální diff: "+3 -5 řádků v auth.ts". Semantic diff: +- "Přidána nová funkce `acquireLock()`" +- "Změněn return type `getUser()` z `User` na `User | null`" +- "Odstraněn dead code: nepoužívaný import a helper" + +Strukturální pohled na to co se *sémanticky* změnilo. Tree-sitter AST diff místo line diff. Reviewer vidí intent, ne textové šumy (přeformátování, přesuny, importy). + +Implementace: tree-sitter parse old + new → AST diff → human-readable summary per file. + +--- + +### 🧪 "What if" sandbox + +Stojíš na změně, říkáš si "co kdyby to agent udělal jinak?" Klikneš "What if..." → napíšeš alternativní přístup → aine pošle agentovi kontext + tvůj návrh → agent vygeneruje alternativní implementaci → vidíš side-by-side: agentova verze vs. alternativa. + +Review se změní z "approve/reject" na **"choose the best approach"**. Kolaborativní design, ne jednosměrný gate. + +--- + +### 🕸️ Impact graph — blast radius změny + +Změnil se interface v `types.ts`. Aine ukáže graf závislostí: +- Které soubory ten interface importují +- Které z nich agent taky změnil (✅ consistent) +- Které NE (⚠️ potenciální breaking change) + +Vizuální "blast radius" každé změny. Reviewer okamžitě vidí jestli agent zapomněl updatnout nějakého consumera. + +Implementace: statická analýza importů + cross-reference s changesetem. + +--- + +### 🎯 Confidence heatmap + +Agent při generování kódu vnitřně "ví" kde si je jistý a kde hádá. Aine to vizualizuje přímo v diffu: +- Řádky s vysokou confidence = normální zobrazení +- Řádky s nízkou confidence = žluté zvýraznění, "⚠ agent uncertain" + +Reviewer ví kde soustředit pozornost — nemusí číst 500 řádků, koukne na 20 žlutých. + +Zdroj: agent by musel poskytnout confidence metadata per-line/per-hunk (nový protocol). + +--- + +### ⏪ Regression anchor — kvantitativní srovnání + +Před review Aine automaticky spustí testy/checks na *původním* i *novém* kódu. Ne jen "testy prošly" ale: +- "Test X předtím trval 50ms, teď 800ms" → perf regression +- "Coverage klesla z 85% na 72% v auth module" +- "Bundle size: 142KB → 158KB (+11%)" + +Kvantitativní before/after, ne jen pass/fail. Čísla, ne jen zelená/červená. + +--- + +### 🗣️ Conversational review + +Místo komentářů na řádcích — chat panel přímo v Aine. "Vysvětli mi proč jsi přidal ten mutex" → agent odpoví s odkazem na konkrétní řádky (`auth.ts:42-48`) → klikneš na odkaz → skočíš tam v diffu → pokračuješ v konverzaci. + +Review jako dialog, ne jako formulář s komentáři. Přirozenější pro interakci s AI. + +Hybridní model: chat pro diskuzi + komentáře pro tracking (z chatu se dají "pinovat" formální review komentáře). + +--- + +### 📸 Snapshot review — velké změny po kouscích + +Agent dělá velkou změnu (100+ souborů). Místo jednoho mamutího review — agent po každém logickém kroku udělá "snapshot": + +1. "Snapshot 1: Extracted shared types" (5 files) → ✅ approved +2. "Snapshot 2: Migrated auth module" (12 files) → ⚠ needs work +3. "Snapshot 3: Updated tests" (8 files) → ☐ not reviewed + +Reviewuješ po stravitelných kouscích. Každý snapshot má vlastní diff, summary, komentáře. Approve jde po snapshots — partial approve je OK. + +Agent řídí granularitu: `aine session snapshot --repo . --name "Extracted types" --files src/types/*.ts` + +--- + +### 🛡️ Trust levels — ne všechno potřebuje review + +Opak klasického review. Definuješ pravidla co agent smí dělat *bez review*: + +```toml +[trust] +auto_approve = ["*.md", "*.json", "test/**"] +always_review = ["**/auth/**", "**/payment/**", "*.sql"] +flag_if = "new_dependency" # nový import/package → vždy flagni +``` + +Aine auto-approves low-risk soubory, zvýrazní jen ty, které opravdu potřebují lidské oči. Místo 40 souborů reviewuješ 8. Zbytek je transparentně schválený s logem proč. + +Postupně se trust model učí: "za poslední měsíc jsi nikdy neměl problém s test soubory → navrhuju auto-approve." + +--- + +### 🧬 Behavioral diff — co se změnilo v chování, ne v textu + +Textový diff říká "řádek 42 se změnil". Behavioral diff říká: +- "Funkce `getUser()` teď throwuje místo return null" +- "API endpoint `/auth` teď přijímá nový query param `force`" +- "Timeout se změnil z 5s na 30s" + +Extrahované z AST + runtime analýzy. Jako changelog, ale automaticky a přesně. + +Bonusový level: "Tady jsou 3 callery `getUser()` — žádný z nich nehandluje throw. 💥" + +--- + +### 🎰 Adversarial mode — zkus to rozbít + +Před review Aine automaticky: +1. Vezme nový kód +2. Nechá jiný AI model, aby se ho pokusil rozbít — edge cases, weird inputs, race conditions +3. Výsledky ukáže inline: "Tohle spadne když `userId` je empty string" + +Reviewer nemusí vymýšlet edge cases — dostane je předžvýkané. Pokud adversarial model nic nenajde, máš vyšší confidence. + +--- + +### 📉 Review budget — máš 10 minut + +Realita: nemáš čas reviewovat 500 řádků pečlivě. Aine to ví. + +Nastavíš si "review budget" (čas nebo effort level). Aine spočítá risk score per soubor a navrhne: +``` +⏱ 10-minute review plan: +1. auth.ts (5 min) — security-critical, large change +2. db.ts (3 min) — new connection pooling logic +3. api.ts (2 min) — skim, mostly mechanical +── auto-approved (low risk) ── +4-12. config, tests, types — agent handled correctly +``` + +Review jako triage, ne jako čtení románu. + +--- + +### 🔁 Pattern consistency checker + +Agent použil pattern X na 3 místech, ale na 4. místě pattern Y. Proč? Buď je to bug, nebo má důvod — ale reviewer by to měl vidět: + +``` +⚠ Inconsistency detected: + src/handlers/auth.ts:15 → uses try/catch + src/handlers/users.ts:22 → uses try/catch + src/handlers/orders.ts:18 → uses try/catch + src/handlers/billing.ts:31 → uses .catch() ← different pattern +``` + +Cross-file pattern analýza. Najde inkonsistence, které lidské oko snadno přehlédne. + +--- + +### 📦 Dependency audit — co agent přitáhl + +Agent přidal `npm install cool-lib`. Aine automaticky: +- Velikost: +340KB minified +- Maintainer: 1 osoba, last commit 8 months ago +- Known CVEs: 0 +- License: MIT ✅ +- Alternativy: `better-lib` (smaller, more maintained) +- Opravdu to potřebuje? Použil z toho 1 funkci, dá se nahradit 5 řádky + +Supply chain review zabudovaný do code review. + +--- + +### 🧠 Review memory — Aine se učí co tě zajímá + +Aine si pamatuje tvoje minulé review komentáře. Vzorce: +- "Vždy komentuje chybějící error handling" +- "Vždy flaguje any type v TypeScriptu" +- "Nikdy nekomentuje import ordering" + +Příště automaticky: "Na základě tvých minulých review — tady jsou 4 místa kde pravděpodobně budeš chtít komentovat" — a rovnou ti je předpřipraví. + +Personalizovaný review assistant, ne generic linter. diff --git a/aine/package.json b/aine/package.json new file mode 100644 index 0000000..ed4806c --- /dev/null +++ b/aine/package.json @@ -0,0 +1,32 @@ +{ + "name": "aine", + "version": "0.1.0", + "description": "Agent-agnostic diff viewer and code review tool", + "license": "MIT", + "bin": { + "aine": "./cli.ts" + }, + "type": "module", + "scripts": { + "build": "bun ./node_modules/.bin/vite build", + "dev": "bun --watch cli.ts & bun ./node_modules/.bin/vite dev & wait", + "start": "bun cli.ts", + "test": "vitest run --config tests/vitest.config.ts", + "test:watch": "vitest --config tests/vitest.config.ts", + "test:api": "vitest run --config tests/vitest.config.ts api/", + "test:unit": "vitest run --config tests/vitest.config.ts unit/", + "test:e2e": "bunx playwright test --config tests/playwright.config.ts" + }, + "dependencies": { + "@pierre/diffs": "latest" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@vitest/coverage-v8": "^4.1.2", + "svelte": "^5.55.0", + "vite": "^8.0.2", + "vite-plugin-singlefile": "^2.3.2", + "vitest": "^4.1.2" + } +} diff --git a/aine/routes.ts b/aine/routes.ts new file mode 100644 index 0000000..37e4e11 --- /dev/null +++ b/aine/routes.ts @@ -0,0 +1,340 @@ +/** + * API route handlers for the Aine HTTP server. + * Each handler is a pure function: (req, res, ctx) → void. + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { DiffSession } from "./diff-session.js"; +import type { ThemeColors } from "./types.js"; + +// ── HTTP helpers ────────────────────────────────────────────── + +export function json(res: ServerResponse, data: unknown) { + res.writeHead(200, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(JSON.stringify(data)); +} + +export function error(res: ServerResponse, code: number, msg: string) { + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: msg })); +} + +export function parseBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (c: Buffer) => { body += c; }); + req.on("end", () => { + try { resolve(JSON.parse(body)); } + catch { reject(new Error("Invalid JSON")); } + }); + }); +} + +// ── Route context ───────────────────────────────────────────── + +export interface RouteContext { + session: DiffSession; + themeUI: ThemeColors; + cwd: string; + diffArgs: string[]; + description: string; +} + +// ── Route handler type ──────────────────────────────────────── + +type Handler = (req: IncomingMessage, res: ServerResponse, ctx: RouteContext) => Promise | void; + +// ── Static routes (exact path match) ────────────────────────── + +const staticRoutes: Record> = { + "/api/theme": { + GET: (_req, res, ctx) => json(res, ctx.themeUI), + }, + + "/api/info": { + GET: (_req, res, ctx) => { + const files = ctx.session.getFiles(); + json(res, { + name: ctx.session.repoName, + branch: ctx.session.branch, + description: ctx.description, + fileCount: files.length, + cached: ctx.session.cachedCount, + total: files.length, + }); + }, + }, + + "/api/files": { + GET: (_req, res, ctx) => json(res, ctx.session.getFiles()), + }, + + "/api/options": { + GET: (_req, res, ctx) => json(res, ctx.session.renderOptions), + POST: async (req, res, ctx) => { + try { + const opts = await parseBody(req); + ctx.session.setOptions(opts); + json(res, ctx.session.renderOptions); + } catch { + error(res, 400, "Invalid JSON"); + } + }, + }, + + "/api/refresh": { + GET: (_req, res, ctx) => { ctx.session.refresh(); json(res, { ok: true }); }, + POST: (_req, res, ctx) => { ctx.session.refresh(); json(res, { ok: true }); }, + }, + + "/api/preload-status": { + GET: (_req, res, ctx) => json(res, ctx.session.preloadStatus()), + }, + + "/api/preload-stream": { + GET: (req, res, ctx) => { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + const send = () => { + const status = ctx.session.preloadStatus(); + res.write(`data: ${JSON.stringify(status)}\n\n`); + if (!status.preloading) { clearInterval(timer); res.end(); } + }; + send(); + const timer = setInterval(send, 300); + req.on("close", () => clearInterval(timer)); + }, + }, + + // ── Session control (for agents) ──────────────────────────── + + "/api/session/context": { + GET: (_req, res, ctx) => { + const files = ctx.session.getFiles(); + json(res, { + repo: ctx.cwd, + repoName: ctx.session.repoName, + branch: ctx.session.branch, + description: ctx.description, + diffArgs: ctx.diffArgs, + selectedFile: ctx.session.currentFile, + files: files.map(f => ({ + path: f.path, status: f.status, + additions: f.additions, deletions: f.deletions, + })), + renderOptions: ctx.session.renderOptions, + threadCount: ctx.session.getThreadCount(), + }); + }, + }, + + "/api/session/navigate": { + POST: async (req, res, ctx) => { + try { + const { file, hunk, line } = await parseBody(req); + ctx.session.currentFile = file; + ctx.session.emitEvent({ type: "navigate", file, hunk, line }); + json(res, { ok: true }); + } catch { + error(res, 400, "Invalid JSON"); + } + }, + }, + + "/api/session/reload": { + POST: async (req, res, ctx) => { + try { + const { diffArgs: newDiffArgs, description: newDesc } = await parseBody(req); + ctx.session.reloadWith(newDiffArgs, newDesc || ""); + json(res, { ok: true }); + } catch { + error(res, 400, "Invalid JSON"); + } + }, + }, + + // ── Threads (list / create) ───────────────────────────────── + + "/api/threads": { + GET: (req, res, ctx) => { + const url = new URL(req.url || "/", "http://localhost"); + const file = url.searchParams.get("file") ?? undefined; + const status = url.searchParams.get("status") as any ?? undefined; + json(res, ctx.session.getThreads({ file, status })); + }, + POST: async (req, res, ctx) => { + try { + const data = await parseBody(req); + json(res, ctx.session.createThread(data)); + } catch (e) { + error(res, 400, String(e)); + } + }, + }, + + // ── SSE events ────────────────────────────────────────────── + + "/api/events": { + GET: (req, res, ctx) => { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + const handler = (event: any) => { + res.write(`data: ${JSON.stringify(event)}\n\n`); + }; + ctx.session.addEventListener(handler); + req.on("close", () => ctx.session.removeEventListener(handler)); + }, + }, +}; + +// ── Dynamic routes (regex match) ────────────────────────────── + +interface DynamicRoute { + pattern: RegExp; + method: string; + handler: (req: IncomingMessage, res: ServerResponse, ctx: RouteContext, match: RegExpMatchArray) => Promise | void; +} + +const dynamicRoutes: DynamicRoute[] = [ + // /api/diff/:path + { + pattern: /^\/api\/diff\/(.+)$/, + method: "GET", + handler: async (_req, res, ctx, m) => { + const filePath = decodeURIComponent(m[1]); + const file = ctx.session.getFileByPath(filePath); + if (!file) { error(res, 404, "Not found"); return; } + try { + const html = await ctx.session.getDiffHTMLForUser(file); + json(res, { file, html }); + } catch (e) { + error(res, 500, String(e)); + } + }, + }, + + // /api/threads/:id/reply + { + pattern: /^\/api\/threads\/([^/]+)\/reply$/, + method: "POST", + handler: async (req, res, ctx, m) => { + try { + const data = await parseBody(req); + const comment = ctx.session.addReply(m[1], data.body, data.author); + if (!comment) error(res, 404, "Thread not found"); + else json(res, comment); + } catch (e) { error(res, 400, String(e)); } + }, + }, + + // /api/threads/:id/status + { + pattern: /^\/api\/threads\/([^/]+)\/status$/, + method: "PATCH", + handler: async (req, res, ctx, m) => { + try { + const data = await parseBody(req); + const thread = ctx.session.updateThreadStatus(m[1], data.status); + if (!thread) error(res, 404, "Thread not found"); + else json(res, thread); + } catch (e) { error(res, 400, String(e)); } + }, + }, + + // /api/threads/:id/edit + { + pattern: /^\/api\/threads\/([^/]+)\/edit$/, + method: "PATCH", + handler: async (req, res, ctx, m) => { + try { + const data = await parseBody(req); + const thread = ctx.session.editThread(m[1], data.body); + if (!thread) error(res, 404, "Thread not found"); + else json(res, thread); + } catch (e) { error(res, 400, String(e)); } + }, + }, + + // /api/threads/:id (GET or DELETE) + { + pattern: /^\/api\/threads\/([^/]+)$/, + method: "GET", + handler: (_req, res, ctx, m) => { + const thread = ctx.session.getThread(m[1]); + if (!thread) error(res, 404, "Thread not found"); + else json(res, thread); + }, + }, + { + pattern: /^\/api\/threads\/([^/]+)$/, + method: "DELETE", + handler: (_req, res, ctx, m) => { + const ok = ctx.session.deleteThread(m[1]); + if (!ok) error(res, 404, "Thread not found"); + else json(res, { ok: true }); + }, + }, +]; + +// ── Router ──────────────────────────────────────────────────── + +/** + * Route an API request. Returns true if handled, false if no route matched + * (caller should serve static HTML). + */ +export function routeRequest( + req: IncomingMessage, + res: ServerResponse, + ctx: RouteContext, +): boolean { + const p = new URL(req.url || "/", "http://localhost").pathname; + const method = req.method || "GET"; + + // CORS preflight + if (method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }); + res.end(); + return true; + } + + // Static routes + const handlers = staticRoutes[p]; + if (handlers) { + const handler = handlers[method]; + if (handler) { + Promise.resolve(handler(req, res, ctx)).catch(e => { + if (!res.headersSent) error(res, 500, String(e)); + }); + return true; + } + } + + // Dynamic routes + for (const route of dynamicRoutes) { + if (route.method !== method) continue; + const m = p.match(route.pattern); + if (m) { + Promise.resolve(route.handler(req, res, ctx, m)).catch(e => { + if (!res.headersSent) error(res, 500, String(e)); + }); + return true; + } + } + + return false; +} diff --git a/aine/server.ts b/aine/server.ts new file mode 100644 index 0000000..c95044d --- /dev/null +++ b/aine/server.ts @@ -0,0 +1,152 @@ +import { createServer } from "node:http"; +import { readFileSync, watch, type FSWatcher } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { DiffSession } from "./diff-session.js"; +import { registerSession, unregisterSession, type SessionInfo } from "./session-store.js"; +import { loadTheme } from "./theme.js"; +import { routeRequest, error, type RouteContext } from "./routes.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const PAGE_HTML = readFileSync(join(__dirname, "dist", "index.html"), "utf-8"); +const PAGE_HTML_BUF = Buffer.from(PAGE_HTML, "utf-8"); +const PAGE_ETAG = `"${Bun.hash(PAGE_HTML).toString(36)}"`; + +export interface ServerOptions { + port: number; + diffArgs: string[]; + description: string; + cwd: string; + unified?: boolean; + noOpen?: boolean; + watch?: boolean; + theme?: string; + expandUnchanged?: boolean; + ignoreWhitespace?: boolean; +} + +// ── Server lifecycle ────────────────────────────────────────── + +let activeServer: ReturnType | null = null; +let activeSession: DiffSession | null = null; +let activeSessionInfo: SessionInfo | null = null; +let activeWatcher: FSWatcher | null = null; + +export function stopServer() { + if (activeWatcher) { + try { activeWatcher.close(); } catch {} + activeWatcher = null; + } + activeSession?.destroy(); + activeSession = null; + if (activeSessionInfo) { + unregisterSession(activeSessionInfo.id); + activeSessionInfo = null; + } + if (activeServer) { + try { activeServer.close(); } catch {} + activeServer = null; + } +} + +function startWatcher(cwd: string, session: DiffSession) { + if (activeWatcher) { + try { activeWatcher.close(); } catch {} + } + let debounceTimer: ReturnType | null = null; + activeWatcher = watch(cwd, { recursive: true }, (_event, filename) => { + if (!filename) return; + if (filename.startsWith(".git/") || filename.startsWith(".git\\")) return; + if (filename.includes("node_modules/")) return; + if (filename.endsWith(".swp") || filename.endsWith("~")) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => session.markStale(), 300); + }); +} + +async function killPort(port: number) { + try { + const proc = Bun.spawn(["lsof", "-ti:" + port], { stdout: "pipe", stderr: "ignore" }); + const text = await new Response(proc.stdout).text(); + for (const pid of text.trim().split("\n").filter(Boolean)) { + try { process.kill(+pid, "SIGKILL"); } catch {} + } + } catch {} +} + +// ── Start server ────────────────────────────────────────────── + +export async function startServer(opts: ServerOptions): Promise { + const { port, diffArgs, description, cwd, unified, noOpen } = opts; + stopServer(); + await killPort(port); + + const theme = loadTheme(opts.theme || "catppuccin-mocha", cwd); + const themeInit = { + name: theme.name, + shikiTheme: theme.shikiTheme, + addBorder: theme.ui.addBorder, + deleteBorder: theme.ui.deleteBorder, + }; + + const session = new DiffSession(diffArgs, cwd, themeInit); + activeSession = session; + + // Apply initial render options from config + const initOpts: Record = {}; + if (unified) initOpts.diffStyle = "unified"; + if (opts.expandUnchanged !== undefined) initOpts.expandUnchanged = opts.expandUnchanged; + if (opts.ignoreWhitespace !== undefined) initOpts.ignoreWhitespace = opts.ignoreWhitespace; + if (Object.keys(initOpts).length > 0) session.setOptions(initOpts); + + session.preloadAll().catch(() => {}); + if (opts.watch) startWatcher(cwd, session); + + const routeCtx: RouteContext = { session, themeUI: theme.ui, cwd, diffArgs, description }; + + return new Promise((resolve, reject) => { + const s = createServer(async (req, res) => { + try { + // Try API routes first + if (routeRequest(req, res, routeCtx)) return; + + // Static: serve the SPA + if (req.headers["if-none-match"] === PAGE_ETAG) { + res.writeHead(304); + res.end(); + return; + } + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "ETag": PAGE_ETAG, + "Cache-Control": "no-cache", + }); + res.end(PAGE_HTML_BUF); + } catch (e) { + if (!res.headersSent) error(res, 500, String(e)); + } + }); + + activeServer = s; + s.on("error", (e: NodeJS.ErrnoException) => + reject(e.code === "EADDRINUSE" ? new Error("Port " + port + " in use") : e), + ); + s.listen(port, "127.0.0.1", () => { + activeSessionInfo = registerSession({ + id: crypto.randomUUID(), + repo: cwd, + repoName: session.repoName, + port, + pid: process.pid, + startedAt: new Date().toISOString(), + diffArgs, + description, + }); + if (!noOpen) { + try { Bun.spawn(["open", `http://localhost:${port}`], { stdout: "ignore", stderr: "ignore" }); } catch {} + } + resolve(port); + }); + }); +} diff --git a/aine/session-cli.ts b/aine/session-cli.ts new file mode 100644 index 0000000..c5e6e2b --- /dev/null +++ b/aine/session-cli.ts @@ -0,0 +1,299 @@ +/** + * `aine session` subcommand handler. + * + * All session commands communicate with a running aine instance via HTTP. + * This is the primary integration point for AI agents. + */ + +import { listSessions, findSessionByRepo, findSessionById, type SessionInfo } from "./session-store.js"; +import { getRepoRoot } from "./git.js"; + +// ── Help ────────────────────────────────────────────────────── + +function printSessionHelp() { + console.log(` +aine session - Control running Aine instances + +COMMANDS + list [--json] List running instances + context (--repo | ) Show current session state + navigate (--repo | ) --file [--hunk N | --line N] + reload (--repo | ) [-- ] + comment add (--repo | ) --file --line [--end-line ] + --summary "..." [--severity must-fix|suggestion|nit] [--author ] + comment reply (--repo | ) --summary "..." + [--author ] + comment list (--repo | ) [--file ] [--status open|resolved|dismissed] [--json] + comment resolve (--repo | ) + comment dismiss (--repo | ) + comment rm (--repo | ) + comment clear (--repo | ) [--file ] --yes +`.trim()); +} + +// ── Arg parsing helpers ─────────────────────────────────────── + +function resolveSession(args: string[]): { session: SessionInfo; remaining: string[] } | null { + const sessions = listSessions(); + + if (sessions.length === 1 && !args.includes("--repo")) { + return { session: sessions[0], remaining: args }; + } + + const repoIdx = args.indexOf("--repo"); + if (repoIdx !== -1 && repoIdx + 1 < args.length) { + const repoPath = args[repoIdx + 1]; + let resolved: string; + try { resolved = getRepoRoot(repoPath === "." ? process.cwd() : repoPath); } + catch { resolved = repoPath; } + const session = findSessionByRepo(resolved); + if (!session) { + console.error(`No active session for repo: ${resolved}\nRun 'aine session list' to see running instances.`); + return null; + } + return { session, remaining: [...args.slice(0, repoIdx), ...args.slice(repoIdx + 2)] }; + } + + if (args.length > 0 && !args[0].startsWith("-")) { + const session = findSessionById(args[0]); + if (session) return { session, remaining: args.slice(1) }; + } + + console.error("No session specified. Use --repo or pass a session ID.\nRun 'aine session list' to see running instances."); + return null; +} + +function parseNamedArgs(args: string[]): { named: Record; positional: string[]; afterDash: string[] } { + const named: Record = {}; + const positional: string[] = []; + const afterDash: string[] = []; + let inAfterDash = false; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--" && !inAfterDash) { inAfterDash = true; continue; } + if (inAfterDash) { afterDash.push(args[i]); continue; } + if (args[i] === "--yes" || args[i] === "--json") { + named[args[i].slice(2)] = "true"; + } else if (args[i].startsWith("--") && i + 1 < args.length && !args[i + 1].startsWith("--")) { + named[args[i].slice(2)] = args[i + 1]; + i++; + } else { + positional.push(args[i]); + } + } + return { named, positional, afterDash }; +} + +async function apiCall(port: number, method: string, path: string, body?: unknown): Promise { + const url = `http://127.0.0.1:${port}${path}`; + const opts: RequestInit = { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }; + const res = await fetch(url, opts); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error((data as any).error || `HTTP ${res.status}`); + } + return res.json(); +} + +/** + * Common pattern: resolve session from args at given offset, + * parse named args, call handler. + */ +function withSession( + args: string[], + offset: number, + handler: (api: (m: string, p: string, b?: unknown) => Promise, named: Record, positional: string[], afterDash: string[]) => Promise, +) { + const resolved = resolveSession(args.slice(offset)); + if (!resolved) process.exit(1); + const { named, positional, afterDash } = parseNamedArgs(resolved.remaining); + const api = (m: string, p: string, b?: unknown) => apiCall(resolved.session.port, m, p, b); + return handler(api, named, positional, afterDash); +} + +function requireArg(named: Record, ...keys: string[]): void { + const missing = keys.filter(k => !named[k]); + if (missing.length > 0) { + console.error(`Error: ${missing.map(k => `--${k}`).join(", ")} required`); + process.exit(1); + } +} + +function requirePositional(positional: string[], label: string): string { + if (!positional[0]) { console.error(`Error: ${label} required`); process.exit(1); } + return positional[0]; +} + +function makeAuthor(name?: string) { + const authorName = name || "agent"; + return { name: authorName, type: authorName === "user" ? "user" as const : "agent" as const }; +} + +// ── Subcommands ─────────────────────────────────────────────── + +export async function sessionCommand(args: string[]) { + if (args.length === 0 || args[0] === "help" || args[0] === "--help") { + printSessionHelp(); + return; + } + + const sub = args[0]; + + if (sub === "list") { + const sessions = listSessions(); + if (args.includes("--json")) { + console.log(JSON.stringify(sessions, null, 2)); + } else if (sessions.length === 0) { + console.log("No active Aine sessions."); + } else { + for (const s of sessions) + console.log(` ${s.repoName} → http://127.0.0.1:${s.port} (pid: ${s.pid}, ${s.description})`); + } + return; + } + + if (sub === "context") { + return withSession(args, 1, async (api, named) => { + const data = await api("GET", "/api/session/context"); + if (named.json === "true" || args.includes("--json")) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(`Repo: ${data.repoName} (${data.branch})`); + console.log(`Diff: ${data.description}`); + console.log(`Files: ${data.files.length}`); + if (data.selectedFile) console.log(`Selected: ${data.selectedFile}`); + if (data.threadCount > 0) console.log(`Threads: ${data.threadCount}`); + console.log(`View: ${data.renderOptions.diffStyle}\n`); + for (const f of data.files) { + const stat = f.status === "added" ? "+" : f.status === "deleted" ? "-" : "~"; + const adds = f.additions > 0 ? ` +${f.additions}` : ""; + const dels = f.deletions > 0 ? ` -${f.deletions}` : ""; + console.log(` ${stat} ${f.path}${adds}${dels}`); + } + } + }); + } + + if (sub === "navigate") { + return withSession(args, 1, async (api, named) => { + requireArg(named, "file"); + await api("POST", "/api/session/navigate", { + file: named.file, + hunk: named.hunk ? parseInt(named.hunk) : undefined, + line: named.line ? parseInt(named.line) : undefined, + }); + console.log(`Navigated to ${named.file}${named.line ? `:${named.line}` : ""}${named.hunk ? ` hunk ${named.hunk}` : ""}`); + }); + } + + if (sub === "reload") { + return withSession(args, 1, async (api, _named, _pos, afterDash) => { + await api("POST", "/api/session/reload", { + diffArgs: afterDash, + description: afterDash.join(" ") || "Reloaded", + }); + console.log("Session reloaded"); + }); + } + + // ── comment subcommands ───────────────────────────────────── + + if (sub === "comment") { + const commentSub = args[1]; + if (!commentSub || commentSub === "help") { printSessionHelp(); return; } + + if (commentSub === "add") { + return withSession(args, 2, async (api, named) => { + requireArg(named, "file", "line", "summary"); + const thread = await api("POST", "/api/threads", { + file: named.file, + startLine: parseInt(named.line), + endLine: named["end-line"] ? parseInt(named["end-line"]) : parseInt(named.line), + body: named.summary, + severity: named.severity, + author: makeAuthor(named.author), + }); + const range = thread.startLine === thread.endLine ? `${thread.startLine}` : `${thread.startLine}-${thread.endLine}`; + console.log(`Thread ${thread.id} created on ${named.file}:${range}`); + }); + } + + if (commentSub === "reply") { + return withSession(args, 2, async (api, named, positional) => { + const id = requirePositional(positional, "thread ID"); + requireArg(named, "summary"); + const comment = await api("POST", `/api/threads/${id}/reply`, { + body: named.summary, + author: makeAuthor(named.author), + }); + console.log(`Reply ${comment.id} added to thread ${id}`); + }); + } + + if (commentSub === "list") { + return withSession(args, 2, async (api, named) => { + const params = new URLSearchParams(); + if (named.file) params.set("file", named.file); + if (named.status) params.set("status", named.status); + const query = params.toString() ? `?${params}` : ""; + const threads = await api("GET", `/api/threads${query}`); + if (named.json === "true" || args.includes("--json")) { + console.log(JSON.stringify(threads, null, 2)); + } else if (threads.length === 0) { + console.log("No threads."); + } else { + for (const t of threads) { + const first = t.comments[0]; + const severity = first?.severity ? ` [${first.severity}]` : ""; + const statusIcon = t.status === "resolved" ? " ✓" : t.status === "dismissed" ? " ✗" : ""; + const range = t.startLine === t.endLine ? `${t.startLine}` : `${t.startLine}-${t.endLine}`; + const replies = t.comments.length > 1 ? ` (${t.comments.length} comments)` : ""; + const body = first?.body || ""; + const truncated = body.length > 60 ? body.slice(0, 57) + "..." : body; + console.log(` ${t.id} ${t.file}:${range}${severity}${statusIcon}${replies} — ${truncated}`); + } + } + }); + } + + if (commentSub === "resolve" || commentSub === "dismiss") { + return withSession(args, 2, async (api, _named, positional) => { + const id = requirePositional(positional, "thread ID"); + const status = commentSub === "resolve" ? "resolved" : "dismissed"; + await api("PATCH", `/api/threads/${id}/status`, { status }); + console.log(`Thread ${id} ${commentSub}${commentSub.endsWith("e") ? "d" : "ed"}`); + }); + } + + if (commentSub === "rm") { + return withSession(args, 2, async (api, _named, positional) => { + const id = requirePositional(positional, "thread ID"); + await api("DELETE", `/api/threads/${id}`); + console.log(`Thread ${id} deleted`); + }); + } + + if (commentSub === "clear") { + return withSession(args, 2, async (api, named) => { + if (named.yes !== "true") { console.error("Error: --yes required to confirm"); process.exit(1); } + const params = new URLSearchParams(); + if (named.file) params.set("file", named.file); + const query = params.toString() ? `?${params}` : ""; + const threads = await api("GET", `/api/threads${query}`); + for (const t of threads) await api("DELETE", `/api/threads/${t.id}`); + console.log(`Cleared ${threads.length} thread(s)`); + }); + } + + console.error(`Unknown comment subcommand: ${commentSub}`); + process.exit(1); + } + + console.error(`Unknown session command: ${sub}`); + printSessionHelp(); + process.exit(1); +} diff --git a/aine/session-store.ts b/aine/session-store.ts new file mode 100644 index 0000000..caca9bf --- /dev/null +++ b/aine/session-store.ts @@ -0,0 +1,105 @@ +/** + * File-based session store for Aine instances. + * + * Each running aine server writes a JSON file to ~/.config/aine/sessions/.json. + * `aine session list` reads all files. Stale sessions (dead PIDs) are cleaned up. + */ + +import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export interface SessionInfo { + id: string; + repo: string; + repoName: string; + port: number; + pid: number; + startedAt: string; + diffArgs: string[]; + description: string; +} + +const SESSIONS_DIR = join(homedir(), ".config", "aine", "sessions"); + +function ensureDir() { + mkdirSync(SESSIONS_DIR, { recursive: true }); +} + +function sessionPath(id: string): string { + return join(SESSIONS_DIR, `${id}.json`); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function registerSession(info: SessionInfo): SessionInfo { + ensureDir(); + writeFileSync(sessionPath(info.id), JSON.stringify(info, null, 2)); + + // Auto-cleanup on exit + const cleanup = () => { + try { unlinkSync(sessionPath(info.id)); } catch {} + }; + process.on("exit", cleanup); + process.on("SIGINT", () => { cleanup(); process.exit(0); }); + process.on("SIGTERM", () => { cleanup(); process.exit(0); }); + + return info; +} + +export function unregisterSession(id: string) { + try { unlinkSync(sessionPath(id)); } catch {} +} + +export function listSessions(): SessionInfo[] { + ensureDir(); + const sessions: SessionInfo[] = []; + const stale: string[] = []; + + for (const file of readdirSync(SESSIONS_DIR)) { + if (!file.endsWith(".json")) continue; + try { + const info: SessionInfo = JSON.parse(readFileSync(join(SESSIONS_DIR, file), "utf-8")); + if (isProcessAlive(info.pid)) { + sessions.push(info); + } else { + stale.push(file); + } + } catch { + stale.push(file); + } + } + + // Clean up stale session files + for (const file of stale) { + try { unlinkSync(join(SESSIONS_DIR, file)); } catch {} + } + + return sessions; +} + +/** Find a session by repo path (resolved to absolute). */ +export function findSessionByRepo(repoPath: string): SessionInfo | null { + const resolved = repoPath.startsWith("/") ? repoPath : join(process.cwd(), repoPath); + const sessions = listSessions(); + return sessions.find(s => s.repo === resolved) ?? null; +} + +/** Find a session by ID. */ +export function findSessionById(id: string): SessionInfo | null { + const p = sessionPath(id); + if (!existsSync(p)) return null; + try { + const info: SessionInfo = JSON.parse(readFileSync(p, "utf-8")); + return isProcessAlive(info.pid) ? info : null; + } catch { + return null; + } +} diff --git a/aine/skills/aine-review/SKILL.md b/aine/skills/aine-review/SKILL.md new file mode 100644 index 0000000..f751f8a --- /dev/null +++ b/aine/skills/aine-review/SKILL.md @@ -0,0 +1,141 @@ +--- +name: aine-review +description: Interacts with live Aine diff review sessions via CLI. Inspects review context, navigates files, reloads sessions, and creates threaded inline review comments with severity levels and replies. Use when the user has an Aine session running or wants to review diffs interactively. +--- + +# Aine Review + +Aine is an agent-agnostic diff viewer and code review tool. The browser UI is for the user — do NOT run `aine`, `aine diff`, or `aine show` directly. Use `aine session *` CLI commands to inspect and control live sessions. + +If no session exists, ask the user to launch Aine in their terminal first: +```bash +aine # working tree changes +aine diff main # diff against main +aine show HEAD~1 # last commit +``` + +## Workflow + +``` +1. aine session list # find live sessions +2. aine session context --repo . # check what's being reviewed +3. aine session navigate ... # move to the right place +4. aine session reload -- ... # swap contents if needed +5. aine session comment add ... # leave review notes +6. aine session comment reply ... # reply to existing threads +``` + +## Session selection + +Every command (except `list`) needs a session target: + +- `--repo ` — match by repo root (most common, use `--repo .`) +- `` — match by exact session ID (when multiple sessions share a repo) +- If only one session exists, it auto-resolves + +## Commands + +### Inspect + +```bash +aine session list [--json] +aine session context (--repo . | ) [--json] +``` + +### Navigate + +```bash +aine session navigate --repo . --file src/App.tsx --hunk 2 +aine session navigate --repo . --file src/App.tsx --line 372 +``` + +- `--hunk ` is 1-based +- `--line ` is 1-based (new file side) + +### Reload + +Swaps what a live session is showing without opening a new browser tab: + +```bash +aine session reload --repo . -- diff +aine session reload --repo . -- diff --staged +aine session reload --repo . -- show HEAD~1 +``` + +### Threads (comments) + +Aine uses a **thread model** for comments. Each thread has a file location (with optional line range), a status, and a chain of comments (original + replies). + +```bash +# Create a new thread (starts a conversation on a line or line range) +aine session comment add --repo . --file README.md --line 103 --summary "Tighten this wording" [--end-line 110] [--severity suggestion] [--author agent] + +# Reply to an existing thread +aine session comment reply --repo . --summary "Good point, will fix" [--author agent] + +# List threads +aine session comment list --repo . [--file README.md] [--status open] [--json] + +# Change thread status +aine session comment resolve --repo . +aine session comment dismiss --repo . + +# Delete a thread +aine session comment rm --repo . + +# Clear all threads +aine session comment clear --repo . --yes [--file README.md] +``` + +- `comment add` requires `--file`, `--line`, and `--summary` +- `--end-line` enables multi-line thread (default: same as `--line`) +- `--severity` is one of: `must-fix`, `suggestion`, `nit` (default: none) +- `--author` defaults to `agent` +- `--status` filter for list: `open`, `resolved`, `dismissed` +- Thread IDs support short prefix lookup (min 4 chars) +- Quote `--summary` defensively in the shell + +## Thread statuses + +| Status | Meaning | +|--------|---------| +| `open` | Active, needs attention | +| `resolved` | Fixed or addressed | +| `dismissed` | Not applicable, won't fix | + +## Severity levels + +| Level | When to use | +|-------|-------------| +| `must-fix` | Bugs, security issues, logic errors — must be addressed | +| `suggestion` | Improvements, better patterns — worth considering | +| `nit` | Style, naming, minor preferences — optional | + +## Guiding a review + +When asked to review code, your role is to narrate and leave actionable comments. + +Typical flow: + +1. `aine session context --repo .` — understand the changeset +2. Navigate to the first interesting file +3. Add a thread explaining what's wrong and why +4. Move to the next point of interest — repeat +5. Summarize findings when done + +Guidelines: + +- Work in the order that tells the clearest story, not file order +- Navigate before commenting so the user sees the relevant code +- Keep comments focused: bugs, security, performance, clarity +- Use severity levels — don't mark everything as `must-fix` +- Use `--end-line` when the issue spans multiple lines +- Don't comment on every line — highlight what matters +- Use `reply` to respond to user questions on existing threads +- After review, suggest the user run resolve to apply fixes + +## Common errors + +- **"No active Aine sessions"** — ask the user to open Aine in their terminal +- **"No active session for repo"** — wrong directory, check with `aine session list` +- **"Thread not found"** — wrong ID, check with `aine session comment list --json` diff --git a/aine/src/App.svelte b/aine/src/App.svelte new file mode 100644 index 0000000..dadae1c --- /dev/null +++ b/aine/src/App.svelte @@ -0,0 +1,251 @@ + + + + +
+ + {#if stale} + + {/if} +
+ + +
+
+ + + + + diff --git a/aine/src/components/CommentInput.svelte b/aine/src/components/CommentInput.svelte new file mode 100644 index 0000000..ce0ce52 --- /dev/null +++ b/aine/src/components/CommentInput.svelte @@ -0,0 +1,83 @@ + + + +
e.stopPropagation()}> +
+ + {#if isEdit && isRange}Editing · Lines {startLine}–{endLine} + {:else if isEdit}Editing · Line {startLine} + {:else if isRange}Lines {startLine}–{endLine} + {:else}Line {startLine}{/if} + + {#if !isEdit && !isRange}shift+click for range{/if} +
+ + +
+ + diff --git a/aine/src/components/CommentsPanel.svelte b/aine/src/components/CommentsPanel.svelte new file mode 100644 index 0000000..7c1f3a1 --- /dev/null +++ b/aine/src/components/CommentsPanel.svelte @@ -0,0 +1,444 @@ + + +
+
+ Comments + {#if unresolvedCount > 0} + {unresolvedCount} + {/if} +
+ +
+ {#if $fileThreads.length === 0} +
+ Click a line number to comment +
+ {:else} +
+ {#each [...openThreads, ...resolvedThreads] as thread (thread.id)} + {@const comment = thread.comments[0]} + {@const author = comment?.author ?? { name: "user", type: "user" }} + {@const avatarColor = getAvatarColor(author)} + {@const accent = getAccentColor(author)} + {@const isFocused = focusedThreadId === thread.id} + {@const isResolved = thread.status === "resolved"} + {@const top = cardPositions.get(thread.id) ?? 0} + + +
handleCardEnter(thread)} + onmouseleave={handleCardLeave} + onclick={() => handleCardClick(thread)} + use:trackCard={thread.id} + > +
+ + {author.name} + {#if isResolved} + ✓ + {/if} + {relativeTime(thread.createdAt)} + +
+
{comment?.body ?? ""}
+
+ {#if !isResolved} + + {/if} + + +
+
+ {/each} +
+ {/if} +
+
+ + diff --git a/aine/src/components/DiffView.svelte b/aine/src/components/DiffView.svelte new file mode 100644 index 0000000..2a521e0 --- /dev/null +++ b/aine/src/components/DiffView.svelte @@ -0,0 +1,682 @@ + + + + + +
+
+
{hunkBadgeText}
+ + {#if showSpinner} +
Loading…
+ {:else if errorMsg} +
{errorMsg}
+ {:else if !currentHtml && !$selectedPath} +
Select a file
+ {/if} + +
+ + {#if commentStartLine !== null && $selectedPath && commentInputTop !== null} + {@const range = commentLineRange()} + {#if range} + + {/if} + {/if} +
+ + diff --git a/aine/src/components/HelpOverlay.svelte b/aine/src/components/HelpOverlay.svelte new file mode 100644 index 0000000..395036d --- /dev/null +++ b/aine/src/components/HelpOverlay.svelte @@ -0,0 +1,129 @@ + + + + +
+
+

Keyboard Shortcuts

+ +
+

Files

+
j / ↓Next file
+
k / ↑Previous file
+
JLast file
+
KFirst file
+
yCopy file path
+
bToggle sidebar
+
h / lCollapse / expand folder
+
H / LCollapse / expand all
+
+ +
+

Diff navigation

+
nNext changed hunk
+
NPrevious changed hunk
+
ggScroll to top
+
GScroll to bottom
+
Ctrl+d / uScroll ½ page down / up
+
Ctrl+f / bScroll full page down / up
+
+ +
+

Display

+
sToggle split / unified
+
wToggle ignore whitespace
+
eToggle expand unchanged
+
+ +
+

Comments

+
cToggle inline comments
+
click gutterAdd comment on line
+
⌘EnterSubmit comment
+
EscCancel comment
+
+ +
+

General

+
rRefresh diff (when files changed)
+
?Toggle this help
+
+ +
Press ? or Esc to close
+
+
+ + diff --git a/aine/src/components/Sidebar.svelte b/aine/src/components/Sidebar.svelte new file mode 100644 index 0000000..4813927 --- /dev/null +++ b/aine/src/components/Sidebar.svelte @@ -0,0 +1,204 @@ + + + + + diff --git a/aine/src/components/Toast.svelte b/aine/src/components/Toast.svelte new file mode 100644 index 0000000..d6bb414 --- /dev/null +++ b/aine/src/components/Toast.svelte @@ -0,0 +1,45 @@ + + +
+ {$toastMessage ?? ""} +
+ + diff --git a/aine/src/components/Toolbar.svelte b/aine/src/components/Toolbar.svelte new file mode 100644 index 0000000..ccb853f --- /dev/null +++ b/aine/src/components/Toolbar.svelte @@ -0,0 +1,293 @@ + + +
+ +
+ {$repoInfo?.name ?? ""} + {$repoInfo?.branch ?? ""} +
+ + │ + + +
+ +
+ + │ + + +
+ {$files.length} file{$files.length !== 1 ? "s" : ""}{#if $totalAdditions > 0}{" "}+{$totalAdditions}{/if}{#if $totalDeletions > 0}{" "}−{$totalDeletions}{/if} +
+ + {#if $preloadStatus.total > 0} + │ + + + + {#if $preloadStatus.preloading} + + {:else} + ✓ + {/if} + cache {$preloadStatus.cached}/{$preloadStatus.total} + + {/if} + + │ + + + │ + + +
+ + + +
+ + │ + + +
+ + +
+
+ + diff --git a/aine/src/index.html b/aine/src/index.html new file mode 100644 index 0000000..88648f4 --- /dev/null +++ b/aine/src/index.html @@ -0,0 +1,12 @@ + + + + + + aine + + +
+ + + diff --git a/aine/src/lib/api.ts b/aine/src/lib/api.ts new file mode 100644 index 0000000..2c34e3a --- /dev/null +++ b/aine/src/lib/api.ts @@ -0,0 +1,106 @@ +import type { ChangedFile, RepoInfo, RenderOptions, Thread, ThreadStatus, ThreadAuthor, Severity } from "./state"; + +export async function fetchTheme(): Promise> { + const r = await fetch("/api/theme"); + return r.json(); +} + +export async function fetchInfo(): Promise { + const r = await fetch("/api/info"); + return r.json(); +} + +export async function fetchFiles(): Promise { + const r = await fetch("/api/files"); + return r.json(); +} + +export async function fetchDiff( + path: string, + signal?: AbortSignal +): Promise<{ file: ChangedFile; html: string } | null> { + const r = await fetch("/api/diff/" + encodeURIComponent(path), { signal }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error((d as any).error || "HTTP " + r.status); + } + return r.json(); +} + +export async function fetchOptions(): Promise { + const r = await fetch("/api/options"); + return r.json(); +} + +export async function updateOptions( + opts: Partial +): Promise { + const r = await fetch("/api/options", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts), + }); + return r.json(); +} + +export async function refresh(): Promise { + await fetch("/api/refresh"); +} + +export async function reloadSession(diffArgs: string[], description: string): Promise { + await fetch("/api/session/reload", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ diffArgs, description }), + }); +} + +// ── Threads API ─────────────────────────────────────────────── + +export async function fetchThreads(file?: string, status?: ThreadStatus): Promise { + const params = new URLSearchParams(); + if (file) params.set("file", file); + if (status) params.set("status", status); + const query = params.toString() ? `?${params}` : ""; + const r = await fetch(`/api/threads${query}`); + return r.json(); +} + +export async function createThread(data: { + file: string; + startLine: number; + endLine?: number; + side?: "old" | "new"; + body: string; + severity?: Severity; + author?: ThreadAuthor; +}): Promise { + const r = await fetch("/api/threads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return r.json(); +} + +export async function editThread(id: string, body: string): Promise { + const r = await fetch(`/api/threads/${id}/edit`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }); + return r.json(); +} + +export async function updateThreadStatus(id: string, status: ThreadStatus): Promise { + const r = await fetch(`/api/threads/${id}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + return r.json(); +} + +export async function deleteThread(id: string): Promise { + await fetch(`/api/threads/${id}`, { method: "DELETE" }); +} diff --git a/aine/src/lib/diff-highlights.ts b/aine/src/lib/diff-highlights.ts new file mode 100644 index 0000000..a4e4874 --- /dev/null +++ b/aine/src/lib/diff-highlights.ts @@ -0,0 +1,177 @@ +/** + * Shadow DOM highlight operations for the diff view. + * Pure functions — no component state, just DOM manipulation. + */ + +import type { Thread } from "../../types.js"; + +/** Get the ShadowRoot from the diff output container. */ +export function getShadow(diffOutEl: HTMLElement | null): ShadowRoot | null { + const wrapper = diffOutEl?.firstChild as HTMLElement | null; + return wrapper?.shadowRoot ?? null; +} + +// ── Gutter selection highlight (comment line range) ─────────── + +export function updateGutterHighlights( + shadow: ShadowRoot, + range: { start: number; end: number } | null, +) { + shadow.querySelectorAll(".aine-gutter-sel").forEach(el => + el.classList.remove("aine-gutter-sel"), + ); + if (!range) return; + + for (const el of shadow.querySelectorAll("[data-line-number-content]")) { + const text = el.textContent?.trim(); + if (!text || !/^\d+$/.test(text)) continue; + const n = parseInt(text); + if (n >= range.start && n <= range.end) { + (el as HTMLElement).classList.add("aine-gutter-sel"); + } + } +} + +// ── Commented lines: gutter stripe per thread ───────────────── + +export function highlightCommentedLines( + shadow: ShadowRoot, + threads: Thread[], +) { + // Clear old + for (const el of shadow.querySelectorAll("[data-aine-comment-line]")) { + (el as HTMLElement).style.boxShadow = ""; + (el as HTMLElement).style.background = ""; + el.removeAttribute("data-aine-comment-line"); + } + + // Build line → color map (agent=blue, user=purple) + const lineColors = new Map(); + for (const thread of threads) { + const isAgent = thread.comments[0]?.author.type === "agent"; + const color = isAgent ? "#89b4fa" : "#cba6f7"; + for (let ln = thread.startLine; ln <= thread.endLine; ln++) { + if (!lineColors.has(ln) || isAgent) lineColors.set(ln, color); + } + } + if (lineColors.size === 0) return; + + // Gutter cells: left stripe + for (const el of shadow.querySelectorAll("[data-line-type][data-column-number]")) { + const ln = parseInt(el.getAttribute("data-column-number") || "0"); + const color = lineColors.get(ln); + if (!color) continue; + (el as HTMLElement).style.boxShadow = `inset 3px 0 0 0 ${color}`; + el.setAttribute("data-aine-comment-line", ""); + } + + // Kill hunk separator box-shadow on commented + adjacent lines + const killShadow = new Set(lineColors.keys()); + for (const thread of threads) { + killShadow.add(thread.startLine - 1); + killShadow.add(thread.endLine + 1); + } + + for (const el of shadow.querySelectorAll("[data-content] > [data-line]")) { + const ln = parseInt(el.getAttribute("data-line") || "0"); + if (!killShadow.has(ln)) continue; + (el as HTMLElement).style.boxShadow = "none"; + el.setAttribute("data-aine-comment-line", ""); + } + + for (const el of shadow.querySelectorAll("[data-line-type][data-column-number]")) { + const ln = parseInt(el.getAttribute("data-column-number") || "0"); + if (lineColors.has(ln)) continue; // already styled with stripe + if (!killShadow.has(ln)) continue; + (el as HTMLElement).style.boxShadow = "none"; + el.setAttribute("data-aine-comment-line", ""); + } +} + +// ── Hover highlight (from CommentsPanel card hover) ─────────── + +export function applyHoverHighlight( + shadow: ShadowRoot, + diffViewEl: HTMLElement, + range: { start: number; end: number } | null, + threads: Thread[], +) { + // Clear previous + for (const el of shadow.querySelectorAll("[data-aine-hover-hl]")) { + (el as HTMLElement).style.background = ""; + (el as HTMLElement).style.outline = ""; + (el as HTMLElement).style.outlineOffset = ""; + (el as HTMLElement).style.boxShadow = ""; + el.removeAttribute("data-aine-hover-hl"); + } + + if (!range) { + highlightCommentedLines(shadow, threads); + return; + } + + const { start, end } = range; + const hoverBg = "rgba(203, 166, 247, 0.15)"; + const hoverStripe = "inset 3px 0 0 0 #cba6f7"; + + for (const el of shadow.querySelectorAll("[data-line-type][data-column-number]")) { + const ln = parseInt(el.getAttribute("data-column-number") || "0"); + if (ln >= start && ln <= end) { + const s = (el as HTMLElement).style; + s.background = hoverBg; + s.boxShadow = hoverStripe; + el.setAttribute("data-aine-hover-hl", ""); + } + } + + for (const el of shadow.querySelectorAll("[data-content] > [data-line]")) { + const ln = parseInt(el.getAttribute("data-line") || "0"); + if (ln >= start && ln <= end) { + const s = (el as HTMLElement).style; + s.background = hoverBg; + s.boxShadow = "none"; + el.setAttribute("data-aine-hover-hl", ""); + } + } + + // Scroll first highlighted line into view + const firstEl = shadow.querySelector(`[data-content] > [data-line="${start}"]`); + if (firstEl && diffViewEl) { + const viewRect = diffViewEl.getBoundingClientRect(); + const elRect = firstEl.getBoundingClientRect(); + if (elRect.top < viewRect.top || elRect.bottom > viewRect.bottom) { + firstEl.scrollIntoView({ block: "center", behavior: "smooth" }); + } + } +} + +// ── Line position helpers ───────────────────────────────────── + +/** Measure bottom Y of a line (relative to scroll container). */ +export function measureLineBottom( + shadow: ShadowRoot, + diffViewEl: HTMLElement, + lineNumber: number, +): number | null { + for (const el of shadow.querySelectorAll("[data-column-number]")) { + if (parseInt(el.getAttribute("data-column-number") || "0") === lineNumber) { + const viewRect = diffViewEl.getBoundingClientRect(); + const rect = el.getBoundingClientRect(); + return rect.bottom - viewRect.top + diffViewEl.scrollTop; + } + } + return null; +} + +/** Get top Y of a line (for CommentsPanel card alignment). */ +export function getLineTopInScroll( + shadow: ShadowRoot, + diffViewEl: HTMLElement, + lineNumber: number, +): number | null { + const el = shadow.querySelector(`[data-content] > [data-line="${lineNumber}"]`); + if (!el) return null; + const viewRect = diffViewEl.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + return elRect.top - viewRect.top + diffViewEl.scrollTop; +} diff --git a/aine/src/lib/events.ts b/aine/src/lib/events.ts new file mode 100644 index 0000000..d33a2db --- /dev/null +++ b/aine/src/lib/events.ts @@ -0,0 +1,50 @@ +/** + * SSE event stream management. + * Returns cleanup functions for use in onMount. + */ + +export function createPreloadStream( + onStatus: (status: { cached: number; total: number; preloading: boolean }) => void, +): () => void { + const es = new EventSource("/api/preload-stream"); + es.onmessage = (e) => { + try { onStatus(JSON.parse(e.data)); } catch {} + }; + es.onerror = () => { es.close(); }; + return () => es.close(); +} + +export interface EventStreamCallbacks { + onNavigate: (file: string) => void; + onStale: () => void; + onRefresh: () => void; +} + +export function createEventStream(callbacks: EventStreamCallbacks): () => void { + let es: EventSource | null = null; + let reconnectTimer: ReturnType | null = null; + + function connect() { + es = new EventSource("/api/events"); + es.onmessage = async (e) => { + try { + const event = JSON.parse(e.data); + if (event.type === "navigate" && event.file) callbacks.onNavigate(event.file); + if (event.type === "stale") callbacks.onStale(); + if (event.type === "reload" || event.type === "refresh") callbacks.onRefresh(); + } catch {} + }; + es.onerror = () => { + es?.close(); + es = null; + reconnectTimer = setTimeout(connect, 2000); + }; + } + + connect(); + + return () => { + if (reconnectTimer) clearTimeout(reconnectTimer); + es?.close(); + }; +} diff --git a/aine/src/lib/file-tree.ts b/aine/src/lib/file-tree.ts new file mode 100644 index 0000000..3d977ca --- /dev/null +++ b/aine/src/lib/file-tree.ts @@ -0,0 +1,134 @@ +/** + * File tree builder and navigation helpers for the sidebar. + * Pure functions — no UI state, just data transformation. + */ + +import type { ChangedFile } from "../../types.js"; + +// ── Types ───────────────────────────────────────────────────── + +export interface TreeNode { + name: string; + kids: Record; + items: { file: ChangedFile; base: string }[]; +} + +export interface SidebarItem { + type: "file" | "folder"; + key: string; +} + +// ── Tree building ───────────────────────────────────────────── + +/** Build a tree from a flat file list, grouping by directory segments. */ +export function buildTree(list: ChangedFile[]): TreeNode { + const root: TreeNode = { name: "", kids: {}, items: [] }; + for (const f of list) { + const parts = f.path.split("/"); + let node = root; + for (let j = 0; j < parts.length - 1; j++) { + if (!node.kids[parts[j]]) + node.kids[parts[j]] = { name: parts[j], kids: {}, items: [] }; + node = node.kids[parts[j]]; + } + node.items.push({ file: f, base: parts[parts.length - 1] }); + } + squash(root); + return root; +} + +/** Collapse single-child directories (e.g. "src/lib" instead of "src" → "lib"). */ +function squash(n: TreeNode) { + for (const k of Object.keys(n.kids)) squash(n.kids[k]); + const keys = Object.keys(n.kids); + if (keys.length === 1 && n.items.length === 0) { + const c = n.kids[keys[0]]; + n.name = n.name ? n.name + "/" + c.name : c.name; + n.kids = c.kids; + n.items = c.items; + } +} + +// ── Item collection ─────────────────────────────────────────── + +/** + * Collect visible sidebar items (folders + files) in DFS visual order. + * Collapsed folders are included but their children are skipped. + */ +export function collectVisibleItems( + node: TreeNode, + prefix: string, + isFolderOpen: (key: string) => boolean, +): SidebarItem[] { + const items: SidebarItem[] = []; + for (const k of Object.keys(node.kids).sort()) { + const child = node.kids[k]; + const folderKey = prefix + "/" + child.name; + items.push({ type: "folder", key: folderKey }); + if (isFolderOpen(folderKey)) { + items.push(...collectVisibleItems(child, folderKey, isFolderOpen)); + } + } + for (const item of node.items.slice().sort((a, b) => a.base.localeCompare(b.base))) { + items.push({ type: "file", key: item.file.path }); + } + return items; +} + +/** Collect all folder keys from the tree (for collapse/expand all). */ +export function collectAllFolderKeys(node: TreeNode, prefix: string): string[] { + const keys: string[] = []; + for (const k of Object.keys(node.kids).sort()) { + const child = node.kids[k]; + const folderKey = prefix + "/" + child.name; + keys.push(folderKey); + keys.push(...collectAllFolderKeys(child, folderKey)); + } + return keys; +} + +// ── Folder lookup ───────────────────────────────────────────── + +/** Find the innermost folder containing the given file path. */ +export function findInnermostFolder(node: TreeNode, prefix: string, path: string): string | null { + for (const k of Object.keys(node.kids).sort()) { + const child = node.kids[k]; + const folderKey = prefix + "/" + child.name; + if (path.startsWith(folderKey + "/")) { + const deeper = findInnermostFolder(child, folderKey, path); + return deeper ?? folderKey; + } + } + return null; +} + +/** Find the outermost collapsed folder containing the given file path. */ +export function findOutermostCollapsedFolder( + node: TreeNode, + prefix: string, + path: string, + isFolderOpen: (key: string) => boolean, +): string | null { + for (const k of Object.keys(node.kids).sort()) { + const child = node.kids[k]; + const folderKey = prefix + "/" + child.name; + if (path.startsWith(folderKey + "/")) { + if (!isFolderOpen(folderKey)) return folderKey; + return findOutermostCollapsedFolder(child, folderKey, path, isFolderOpen); + } + } + return null; +} + +// ── Display helpers ─────────────────────────────────────────── + +export function statusGlyph(status: string): string { + switch (status) { + case "added": return "+"; + case "deleted": return "−"; + case "modified": return "·"; + case "renamed": return "→"; + case "copied": return "©"; + default: return "?"; + } +} diff --git a/aine/src/lib/keyboard.ts b/aine/src/lib/keyboard.ts new file mode 100644 index 0000000..23e2b50 --- /dev/null +++ b/aine/src/lib/keyboard.ts @@ -0,0 +1,97 @@ +/** + * Keyboard handler - dispatches actions based on key presses. + * Returns an action descriptor so the caller (App.svelte) can handle side effects. + */ + +export type Action = + | { type: "nextFile" } + | { type: "prevFile" } + | { type: "firstFile" } + | { type: "lastFile" } + | { type: "copyPath" } + | { type: "toggleSidebar" } + | { type: "collapseFolder" } + | { type: "expandFolder" } + | { type: "collapseAll" } + | { type: "expandAll" } + | { type: "nextHunk" } + | { type: "prevHunk" } + | { type: "scrollTop" } + | { type: "scrollBottom" } + | { type: "scrollHalfDown" } + | { type: "scrollHalfUp" } + | { type: "scrollPageDown" } + | { type: "scrollPageUp" } + | { type: "toggleStyle" } + | { type: "toggleWhitespace" } + | { type: "toggleExpand" } + | { type: "toggleHelp" } + | { type: "toggleComments" } + | { type: "refresh" } + | null; + +let pendingG = false; + +export function handleKeydown(e: KeyboardEvent): Action { + if ( + (e.target as HTMLElement).tagName === "INPUT" || + (e.target as HTMLElement).tagName === "TEXTAREA" + ) + return null; + + if (e.key === "?") { + e.preventDefault(); + return { type: "toggleHelp" }; + } + + if (e.key === "Escape") { + e.preventDefault(); + return { type: "toggleHelp" }; // App will check if help is visible + } + + if (e.ctrlKey) { + switch (e.key) { + case "d": e.preventDefault(); return { type: "scrollHalfDown" }; + case "u": e.preventDefault(); return { type: "scrollHalfUp" }; + case "f": e.preventDefault(); return { type: "scrollPageDown" }; + case "b": e.preventDefault(); return { type: "scrollPageUp" }; + } + return null; + } + + // gg sequence + if (e.key === "g" && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + if (pendingG) { + e.preventDefault(); + pendingG = false; + return { type: "scrollTop" }; + } + pendingG = true; + setTimeout(() => { pendingG = false; }, 500); + return null; + } + pendingG = false; + + switch (e.key) { + case "G": e.preventDefault(); return { type: "scrollBottom" }; + case "n": e.preventDefault(); return { type: "nextHunk" }; + case "N": e.preventDefault(); return { type: "prevHunk" }; + case "s": e.preventDefault(); return { type: "toggleStyle" }; + case "w": e.preventDefault(); return { type: "toggleWhitespace" }; + case "e": e.preventDefault(); return { type: "toggleExpand" }; + case "y": e.preventDefault(); return { type: "copyPath" }; + case "b": e.preventDefault(); return { type: "toggleSidebar" }; + case "h": e.preventDefault(); return { type: "collapseFolder" }; + case "l": e.preventDefault(); return { type: "expandFolder" }; + case "H": e.preventDefault(); return { type: "collapseAll" }; + case "L": e.preventDefault(); return { type: "expandAll" }; + case "c": e.preventDefault(); return { type: "toggleComments" }; + case "r": e.preventDefault(); return { type: "refresh" }; + case "j": case "ArrowDown": e.preventDefault(); return { type: "nextFile" }; + case "k": case "ArrowUp": e.preventDefault(); return { type: "prevFile" }; + case "K": e.preventDefault(); return { type: "firstFile" }; + case "J": e.preventDefault(); return { type: "lastFile" }; + } + + return null; +} diff --git a/aine/src/lib/state.ts b/aine/src/lib/state.ts new file mode 100644 index 0000000..22cf04b --- /dev/null +++ b/aine/src/lib/state.ts @@ -0,0 +1,53 @@ +import { writable, derived } from "svelte/store"; +import type { + ChangedFile, + RepoInfo, + RenderOptions, + Thread, +} from "../../types.js"; + +export type { + ChangedFile, + RepoInfo, + RenderOptions, + Severity, + ThreadStatus, + ThreadAuthor, + ThreadComment, + Thread, +} from "../../types.js"; + +export const files = writable([]); +export const repoInfo = writable(null); +export const selectedPath = writable(null); +export const renderOptions = writable({ + diffStyle: "split", + expandUnchanged: true, + ignoreWhitespace: false, +}); +export const helpVisible = writable(false); +export const sidebarVisible = writable(true); +export const toastMessage = writable(null); +export const preloadStatus = writable<{ cached: number; total: number; preloading: boolean }>({ + cached: 0, + total: 0, + preloading: false, +}); +export const threads = writable([]); +export const commentsPanelVisible = writable(true); + +export const totalAdditions = derived(files, ($files) => + $files.reduce((sum, f) => sum + f.additions, 0) +); +export const totalDeletions = derived(files, ($files) => + $files.reduce((sum, f) => sum + f.deletions, 0) +); + +/** Threads for the currently selected file. */ +export const fileThreads = derived( + [threads, selectedPath], + ([$threads, $selectedPath]) => { + if (!$selectedPath) return []; + return $threads.filter(t => t.file === $selectedPath); + } +); diff --git a/aine/src/lib/theme-apply.ts b/aine/src/lib/theme-apply.ts new file mode 100644 index 0000000..e70a053 --- /dev/null +++ b/aine/src/lib/theme-apply.ts @@ -0,0 +1,21 @@ +/** + * Apply theme colors to CSS custom properties. + */ +export function applyTheme(t: Record) { + const root = document.documentElement; + root.style.setProperty("--bg", t.bg); + root.style.setProperty("--sidebar", t.sidebar); + root.style.setProperty("--hover", t.hover); + root.style.setProperty("--active", t.active); + root.style.setProperty("--text", t.text); + root.style.setProperty("--muted", t.muted); + root.style.setProperty("--accent", t.accent); + root.style.setProperty("--border", t.border); + root.style.setProperty("--add", t.add); + root.style.setProperty("--del", t.delete); + root.style.setProperty("--mod", t.modified); + root.style.setProperty("--diffs-bg-addition-override", t.addBg); + root.style.setProperty("--diffs-bg-addition-number-override", t.addBg); + root.style.setProperty("--diffs-bg-deletion-override", t.deleteBg); + root.style.setProperty("--diffs-bg-deletion-number-override", t.deleteBg); +} diff --git a/aine/src/main.ts b/aine/src/main.ts new file mode 100644 index 0000000..b1f2754 --- /dev/null +++ b/aine/src/main.ts @@ -0,0 +1,6 @@ +import App from "./App.svelte"; +import { mount } from "svelte"; + +const app = mount(App, { target: document.getElementById("app")! }); + +export default app; diff --git a/aine/ssr-worker-client.ts b/aine/ssr-worker-client.ts new file mode 100644 index 0000000..7492402 --- /dev/null +++ b/aine/ssr-worker-client.ts @@ -0,0 +1,190 @@ +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, type ChildProcess } from "node:child_process"; +import type { ChangedFile, RenderOptions } from "./types.js"; + + + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export interface DiffAnnotation { + side: "deletions" | "additions"; + lineNumber: number; +} + +export interface ThemeInit { + name: string; + shikiTheme: Record; + addBorder: string; + deleteBorder: string; +} + +const LARGE_FILE_THRESHOLD = 3000; + +function countLines(s: string | null): number { + if (!s) return 0; + let count = 1; + let pos = 0; + while ((pos = s.indexOf("\n", pos)) !== -1) { count++; pos++; } + return count; +} + +export function stripTrailingWS(s: string | null): string | null { + if (s == null) return s; + return s.replace(/[^\S\n]+$/gm, ""); +} + +/** + * Manages a Bun subprocess that renders diffs via @pierre/diffs SSR. + * Communicates via JSON lines over stdin/stdout. + * + * The worker must receive an initTheme message before it becomes ready. + */ +export class SSRWorker { + private proc: ChildProcess | null = null; + private ready = false; + private nextId = 0; + private pending = new Map< + number, + { resolve: (html: string) => void; reject: (err: Error) => void } + >(); + private buf = ""; + private starting: Promise | null = null; + private themeInit: ThemeInit | null = null; + + /** Set theme data before first ensure(). */ + setTheme(theme: ThemeInit) { + this.themeInit = theme; + } + + async ensure(): Promise { + if (this.proc && this.ready) return; + if (this.starting) return this.starting; + this.starting = new Promise((resolve, reject) => { + this.proc = spawn("bun", ["run", join(__dirname, "ssr-worker.ts")], { + stdio: ["pipe", "pipe", "inherit"], + cwd: __dirname, + }); + + this.proc.stdin!.on("error", () => {}); + + this.proc.on("exit", (code) => { + this.proc = null; + this.ready = false; + this.starting = null; + this.rejectAll("SSR worker exited: " + code); + }); + + this.proc.stdout!.on("data", (chunk: Buffer) => { + this.buf += chunk.toString(); + let nl: number; + while ((nl = this.buf.indexOf("\n")) !== -1) { + const line = this.buf.slice(0, nl); + this.buf = this.buf.slice(nl + 1); + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.id === 0 && msg.ready) { + this.ready = true; + this.starting = null; + resolve(); + continue; + } + const cb = this.pending.get(msg.id); + if (cb) { + this.pending.delete(msg.id); + msg.error + ? cb.reject(new Error(msg.error)) + : cb.resolve(msg.html); + } + } catch {} + } + }); + + // Send init theme immediately after spawn + if (this.themeInit) { + try { + this.proc.stdin!.write( + JSON.stringify({ id: 0, initTheme: this.themeInit }) + "\n", + ); + } catch {} + } + + setTimeout(() => { + if (!this.ready) { + this.starting = null; + reject(new Error("SSR worker timeout")); + } + }, 15_000); + }); + return this.starting; + } + + preloadLangs(langs: string[]) { + if (!this.proc || !this.ready || langs.length === 0) return; + try { + this.proc.stdin!.write( + JSON.stringify({ id: -1, preloadLangs: langs }) + "\n", + ); + } catch {} + } + + async render( + file: ChangedFile, + oldContent: string | null, + newContent: string | null, + renderOpts?: RenderOptions, + annotations?: DiffAnnotation[], + ): Promise { + await this.ensure(); + const id = ++this.nextId; + + const lineCount = countLines(oldContent) + countLines(newContent); + const isLarge = lineCount > LARGE_FILE_THRESHOLD; + + const options: Record = {}; + if (renderOpts?.diffStyle) { + options.diffStyle = isLarge ? "split" : renderOpts.diffStyle; + } + if (renderOpts?.expandUnchanged !== undefined) { + options.expandUnchanged = isLarge ? false : renderOpts.expandUnchanged; + } + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + try { + const msg: Record = { + id, + oldFile: { + name: file.oldPath || file.path, + contents: oldContent ?? "", + }, + newFile: { name: file.path, contents: newContent ?? "" }, + options, + }; + if (annotations && annotations.length > 0) { + msg.annotations = annotations; + } + this.proc!.stdin!.write(JSON.stringify(msg) + "\n"); + } catch (e) { + this.pending.delete(id); + reject(new Error("Failed to write to worker: " + e)); + } + }); + } + + kill() { + if (this.proc) { + try { + this.proc.kill(); + } catch {} + this.proc = null; + this.ready = false; + } + this.rejectAll("Worker killed"); + } + + private rejectAll(msg: string) { + for (const [, cb] of this.pending) cb.reject(new Error(msg)); + this.pending.clear(); + } +} diff --git a/aine/ssr-worker.ts b/aine/ssr-worker.ts new file mode 100644 index 0000000..1a32737 --- /dev/null +++ b/aine/ssr-worker.ts @@ -0,0 +1,138 @@ +/** + * Persistent Bun subprocess for SSR diff rendering. + * Protocol: JSON lines over stdin/stdout. + * + * Init: { "id": 0, "initTheme": { name, shikiTheme, unsafeCSS } } + * Request: { "id": 1, "oldFile": {...}, "newFile": {...}, "options": {...} } + * Response: { "id": 1, "html": "..." } or { "id": 1, "error": "..." } + * Ready: { "id": 0, "ready": true } + * Preload: { "id": -1, "preloadLangs": ["python", "go", ...] } + */ + +import { createInterface } from "node:readline"; +import { preloadDiffHTML } from "@pierre/diffs/ssr"; +import { registerCustomTheme, preloadHighlighter } from "@pierre/diffs"; + +// Suppress EPIPE errors when parent process kills us +process.on("uncaughtException", (err: any) => { + if (err?.code === "EPIPE") process.exit(0); + throw err; +}); +process.stdout.on("error", (err: any) => { + if (err?.code === "EPIPE") process.exit(0); +}); + +let THEME_NAME = "aine-theme"; +let UNSAFE_CSS = ""; +let DEFAULT_OPTIONS: Record = { + diffIndicators: "none" as const, + diffStyle: "split" as const, + expandUnchanged: true, +}; + +function buildUnsafeCSS(addBorder: string, deleteBorder: string): string { + return ` + [data-line-type='change-addition'] { + box-shadow: inset 0 1px 0 ${addBorder}, inset 0 -1px 0 ${addBorder}; + } + [data-line-type='change-addition'] + [data-line-type='change-addition'] { + box-shadow: inset 0 -1px 0 ${addBorder}; + } + [data-line-type='change-addition']:has(+ [data-line-type='change-addition']) { + box-shadow: inset 0 1px 0 ${addBorder}; + } + [data-line-type='change-addition'] + [data-line-type='change-addition']:has(+ [data-line-type='change-addition']) { + box-shadow: none; + } + [data-line-type='change-deletion'] { + box-shadow: inset 0 1px 0 ${deleteBorder}, inset 0 -1px 0 ${deleteBorder}; + } + [data-line-type='change-deletion'] + [data-line-type='change-deletion'] { + box-shadow: inset 0 -1px 0 ${deleteBorder}; + } + [data-line-type='change-deletion']:has(+ [data-line-type='change-deletion']) { + box-shadow: inset 0 1px 0 ${deleteBorder}; + } + [data-line-type='change-deletion'] + [data-line-type='change-deletion']:has(+ [data-line-type='change-deletion']) { + box-shadow: none; + } +`; +} + +const rl = createInterface({ input: process.stdin }); +let initialized = false; + +for await (const line of rl) { + if (!line.trim()) continue; + let req: any; + try { + req = JSON.parse(line); + } catch { + continue; + } + + // Init message with theme data + if (req.initTheme && !initialized) { + initialized = true; + const { name, shikiTheme, addBorder, deleteBorder } = req.initTheme; + THEME_NAME = name || "aine-theme"; + UNSAFE_CSS = buildUnsafeCSS( + addBorder || "rgba(166, 227, 161, 0.25)", + deleteBorder || "rgba(243, 139, 168, 0.25)", + ); + DEFAULT_OPTIONS = { + ...DEFAULT_OPTIONS, + theme: THEME_NAME, + unsafeCSS: UNSAFE_CSS, + }; + + if (shikiTheme && Object.keys(shikiTheme).length > 0) { + registerCustomTheme(THEME_NAME, async () => ({ + ...shikiTheme, + name: THEME_NAME, + })); + } + + await preloadHighlighter({ + themes: [THEME_NAME], + langs: [ + "typescript", "javascript", "tsx", "jsx", + "json", "yaml", "toml", "markdown", + "css", "html", "svelte", + "python", "go", "rust", "bash", "shell", + ], + }).catch(() => {}); + + // Warmup + await preloadDiffHTML({ + oldFile: { name: "warmup.ts", contents: "const x = 1;" }, + newFile: { name: "warmup.ts", contents: "const x = 2;" }, + options: DEFAULT_OPTIONS, + }).catch(() => {}); + + try { process.stdout.write(JSON.stringify({ id: 0, ready: true }) + "\n"); } catch {} + continue; + } + + // Language preload + if (req.preloadLangs) { + preloadHighlighter({ themes: [], langs: req.preloadLangs }).catch(() => {}); + continue; + } + + // Render request + (async (r: any) => { + try { + const opts = { ...DEFAULT_OPTIONS, ...r.options }; + const html = await preloadDiffHTML({ + oldFile: r.oldFile, + newFile: r.newFile, + options: opts, + annotations: r.annotations, + }); + try { process.stdout.write(JSON.stringify({ id: r.id, html }) + "\n"); } catch {} + } catch (e) { + try { process.stdout.write(JSON.stringify({ id: r.id, error: String(e) }) + "\n"); } catch {} + } + })(req); +} diff --git a/aine/svelte.config.js b/aine/svelte.config.js new file mode 100644 index 0000000..d0e6448 --- /dev/null +++ b/aine/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/aine/tests/api/server.test.ts b/aine/tests/api/server.test.ts new file mode 100644 index 0000000..5811206 --- /dev/null +++ b/aine/tests/api/server.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { + startTestServer, + api, + apiPost, + type TestServer, +} from "../fixtures/test-server.js"; + +describe("API – basic endpoints", () => { + let server: TestServer; + + beforeAll(async () => { + server = await startTestServer(); + }, 15_000); + + afterAll(() => { + server.cleanup(); + }); + + it("GET /api/info returns repo info", async () => { + const { status, data } = await api(server.base, "/api/info"); + expect(status).toBe(200); + expect(data.name).toMatch(/aine-test-/); + expect(typeof data.branch).toBe("string"); + expect(data.fileCount).toBe(3); // modified, added, deleted + expect(typeof data.description).toBe("string"); + }); + + it("GET /api/files returns changed files", async () => { + const { status, data } = await api(server.base, "/api/files"); + expect(status).toBe(200); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(3); + + const paths = data.map((f: any) => f.path).sort(); + expect(paths).toEqual(["hello.ts", "newfile.ts", "utils.ts"]); + + const hello = data.find((f: any) => f.path === "hello.ts"); + expect(hello.status).toBe("modified"); + + const newfile = data.find((f: any) => f.path === "newfile.ts"); + expect(newfile.status).toBe("added"); + + const utils = data.find((f: any) => f.path === "utils.ts"); + expect(utils.status).toBe("deleted"); + }); + + it("GET /api/theme returns theme colors", async () => { + const { status, data } = await api(server.base, "/api/theme"); + expect(status).toBe(200); + expect(typeof data.bg).toBe("string"); + expect(typeof data.text).toBe("string"); + expect(typeof data.accent).toBe("string"); + }); + + it("GET /api/options returns render options", async () => { + const { status, data } = await api(server.base, "/api/options"); + expect(status).toBe(200); + expect(data.diffStyle).toMatch(/^(split|unified)$/); + expect(typeof data.expandUnchanged).toBe("boolean"); + expect(typeof data.ignoreWhitespace).toBe("boolean"); + }); + + it("POST /api/options updates and returns new options", async () => { + const { status, data } = await apiPost(server.base, "/api/options", { + diffStyle: "unified", + }); + expect(status).toBe(200); + expect(data.diffStyle).toBe("unified"); + + // Verify it persisted + const { data: opts2 } = await api(server.base, "/api/options"); + expect(opts2.diffStyle).toBe("unified"); + + // Reset + await apiPost(server.base, "/api/options", { diffStyle: "split" }); + }); + + it("GET /api/diff/:path returns HTML diff for modified file", async () => { + const { status, data } = await api( + server.base, + "/api/diff/" + encodeURIComponent("hello.ts"), + ); + expect(status).toBe(200); + expect(data.file.path).toBe("hello.ts"); + expect(typeof data.html).toBe("string"); + expect(data.html.length).toBeGreaterThan(0); + }); + + it("GET /api/diff/:path returns HTML diff for added file", async () => { + const { status, data } = await api( + server.base, + "/api/diff/" + encodeURIComponent("newfile.ts"), + ); + expect(status).toBe(200); + expect(data.file.path).toBe("newfile.ts"); + expect(data.html).toContain("newFile"); + }); + + it("GET /api/diff/:path returns HTML diff for deleted file", async () => { + const { status, data } = await api( + server.base, + "/api/diff/" + encodeURIComponent("utils.ts"), + ); + expect(status).toBe(200); + expect(data.file.path).toBe("utils.ts"); + expect(data.html).toContain("add"); + }); + + it("GET /api/diff/:path returns 404 for unknown file", async () => { + const { status, data } = await api( + server.base, + "/api/diff/" + encodeURIComponent("nonexistent.ts"), + ); + expect(status).toBe(404); + expect(data.error).toBeDefined(); + }); + + it("GET /api/refresh returns ok", async () => { + const { status, data } = await api(server.base, "/api/refresh"); + expect(status).toBe(200); + expect(data.ok).toBe(true); + }); + + it("GET /api/preload-status returns preload info", async () => { + const { status, data } = await api(server.base, "/api/preload-status"); + expect(status).toBe(200); + expect(typeof data.cached).toBe("number"); + expect(typeof data.total).toBe("number"); + expect(typeof data.preloading).toBe("boolean"); + }); +}); diff --git a/aine/tests/api/session.test.ts b/aine/tests/api/session.test.ts new file mode 100644 index 0000000..ef3c0b8 --- /dev/null +++ b/aine/tests/api/session.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { + startTestServer, + api, + apiPost, + type TestServer, +} from "../fixtures/test-server.js"; + +describe("API – session management", () => { + let server: TestServer; + + beforeAll(async () => { + server = await startTestServer(); + }, 15_000); + + afterAll(() => { + server.cleanup(); + }); + + it("GET /api/session/context returns full session state", async () => { + const { status, data } = await api(server.base, "/api/session/context"); + expect(status).toBe(200); + // macOS resolves /var → /private/var, so normalize both + expect(data.repo.replace("/private/var", "/var")).toBe( + server.repo.dir.replace("/private/var", "/var"), + ); + expect(typeof data.repoName).toBe("string"); + expect(typeof data.branch).toBe("string"); + expect(typeof data.description).toBe("string"); + expect(Array.isArray(data.diffArgs)).toBe(true); + expect(Array.isArray(data.files)).toBe(true); + expect(data.files.length).toBe(3); + expect(typeof data.renderOptions).toBe("object"); + expect(typeof data.threadCount).toBe("number"); + }); + + it("POST /api/session/navigate sets current file", async () => { + const { status, data } = await apiPost( + server.base, + "/api/session/navigate", + { file: "hello.ts" }, + ); + expect(status).toBe(200); + expect(data.ok).toBe(true); + + // Verify via context + const { data: ctx } = await api(server.base, "/api/session/context"); + expect(ctx.selectedFile).toBe("hello.ts"); + }); + + it("POST /api/session/navigate with hunk and line", async () => { + const { status, data } = await apiPost( + server.base, + "/api/session/navigate", + { file: "hello.ts", hunk: 0, line: 1 }, + ); + expect(status).toBe(200); + expect(data.ok).toBe(true); + }); + + it("POST /api/session/reload reloads with new diffArgs", async () => { + const { status, data } = await apiPost( + server.base, + "/api/session/reload", + { diffArgs: ["HEAD~1..HEAD"], description: "Last commit" }, + ); + expect(status).toBe(200); + expect(data.ok).toBe(true); + + // Reload triggers a refresh – files should update. + // Wait a bit for async reload to settle. + await new Promise((r) => setTimeout(r, 500)); + + // Verify files changed (HEAD~1..HEAD = the initial commit shows no diff + // since there's only one commit in fixture, so file list may differ) + const { data: ctx } = await api(server.base, "/api/session/context"); + expect(ctx.repo).toBeDefined(); + expect(Array.isArray(ctx.files)).toBe(true); + }); + + it("SSE /api/events stream works", async () => { + // Connect to event stream + const controller = new AbortController(); + const eventsPromise = fetch(`${server.base}/api/events`, { + signal: controller.signal, + }); + + // Give SSE time to connect + await new Promise((r) => setTimeout(r, 300)); + + // Trigger a navigate event + await apiPost(server.base, "/api/session/navigate", { + file: "newfile.ts", + }); + + // Wait a bit for event to propagate + await new Promise((r) => setTimeout(r, 300)); + + // Abort and read what we got + controller.abort(); + + try { + const response = await eventsPromise; + const text = await response.text(); + // Should contain at least a navigate event + expect(text).toContain("data:"); + expect(text).toContain("navigate"); + } catch (e: any) { + // AbortError is expected + if (e.name !== "AbortError") throw e; + } + }); + + it("OPTIONS requests return CORS headers", async () => { + const r = await fetch(`${server.base}/api/info`, { method: "OPTIONS" }); + expect(r.status).toBe(204); + expect(r.headers.get("access-control-allow-origin")).toBe("*"); + }); + + it("Unknown API path returns HTML (SPA fallback)", async () => { + const r = await fetch(`${server.base}/unknown-path`); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toContain("text/html"); + }); +}); diff --git a/aine/tests/api/threads.test.ts b/aine/tests/api/threads.test.ts new file mode 100644 index 0000000..9ba5849 --- /dev/null +++ b/aine/tests/api/threads.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { + startTestServer, + api, + apiPost, + apiPatch, + apiDelete, + type TestServer, +} from "../fixtures/test-server.js"; + +describe("API – threads CRUD", () => { + let server: TestServer; + + beforeAll(async () => { + server = await startTestServer(); + }, 15_000); + + afterAll(() => { + server.cleanup(); + }); + + it("GET /api/threads returns empty array initially", async () => { + const { status, data } = await api(server.base, "/api/threads"); + expect(status).toBe(200); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(0); + }); + + it("POST /api/threads creates a thread", async () => { + const { status, data } = await apiPost(server.base, "/api/threads", { + file: "hello.ts", + startLine: 1, + side: "new", + body: "This should use a constant", + severity: "suggestion", + }); + expect(status).toBe(200); + expect(data.id).toBeDefined(); + expect(data.file).toBe("hello.ts"); + expect(data.startLine).toBe(1); + expect(data.status).toBe("open"); + expect(data.comments).toHaveLength(1); + expect(data.comments[0].body).toBe("This should use a constant"); + expect(data.comments[0].severity).toBe("suggestion"); + }); + + it("GET /api/threads lists created threads", async () => { + const { data } = await api(server.base, "/api/threads"); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0].file).toBe("hello.ts"); + }); + + it("GET /api/threads?file=hello.ts filters by file", async () => { + // Create a thread on another file + await apiPost(server.base, "/api/threads", { + file: "newfile.ts", + startLine: 1, + side: "new", + body: "Another comment", + }); + + const { data: all } = await api(server.base, "/api/threads"); + expect(all.length).toBeGreaterThanOrEqual(2); + + const { data: filtered } = await api( + server.base, + "/api/threads?file=hello.ts", + ); + expect(filtered.every((t: any) => t.file === "hello.ts")).toBe(true); + }); + + it("GET /api/threads/:id returns single thread", async () => { + const { data: threads } = await api(server.base, "/api/threads"); + const id = threads[0].id; + + const { status, data } = await api(server.base, `/api/threads/${id}`); + expect(status).toBe(200); + expect(data.id).toBe(id); + expect(data.comments).toBeDefined(); + }); + + it("GET /api/threads/:id returns 404 for unknown id", async () => { + const { status, data } = await api( + server.base, + "/api/threads/nonexistent", + ); + expect(status).toBe(404); + expect(data.error).toBeDefined(); + }); + + it("PATCH /api/threads/:id/status resolves a thread", async () => { + const { data: threads } = await api(server.base, "/api/threads"); + const id = threads[0].id; + + const { status, data } = await apiPatch( + server.base, + `/api/threads/${id}/status`, + { status: "resolved" }, + ); + expect(status).toBe(200); + expect(data.status).toBe("resolved"); + }); + + it("GET /api/threads?status=open filters by status", async () => { + const { data } = await api(server.base, "/api/threads?status=open"); + expect(data.every((t: any) => t.status === "open")).toBe(true); + }); + + it("PATCH /api/threads/:id/edit edits thread body", async () => { + const { data: threads } = await api(server.base, "/api/threads"); + const id = threads[0].id; + + const { status, data } = await apiPatch( + server.base, + `/api/threads/${id}/edit`, + { body: "Updated comment text" }, + ); + expect(status).toBe(200); + // Verify the edit took effect + const { data: thread } = await api(server.base, `/api/threads/${id}`); + const lastComment = thread.comments[thread.comments.length - 1]; + expect(lastComment.body).toBe("Updated comment text"); + }); + + it("PATCH /api/threads/:id/status returns 404 for unknown id", async () => { + const { status } = await apiPatch( + server.base, + "/api/threads/nonexistent/status", + { status: "resolved" }, + ); + expect(status).toBe(404); + }); + + it("DELETE /api/threads/:id deletes a thread", async () => { + // Create a throwaway thread + const { data: created } = await apiPost(server.base, "/api/threads", { + file: "hello.ts", + startLine: 1, + side: "new", + body: "To be deleted", + }); + + const { status } = await apiDelete( + server.base, + `/api/threads/${created.id}`, + ); + expect(status).toBe(200); + + // Verify it's gone + const { status: getStatus } = await api( + server.base, + `/api/threads/${created.id}`, + ); + expect(getStatus).toBe(404); + }); + + it("DELETE /api/threads/:id returns 404 for unknown id", async () => { + const { status } = await apiDelete( + server.base, + "/api/threads/nonexistent", + ); + expect(status).toBe(404); + }); +}); diff --git a/aine/tests/coverage/base.css b/aine/tests/coverage/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/aine/tests/coverage/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/aine/tests/coverage/block-navigation.js b/aine/tests/coverage/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/aine/tests/coverage/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/aine/tests/coverage/clover.xml b/aine/tests/coverage/clover.xml new file mode 100644 index 0000000..d8808d2 --- /dev/null +++ b/aine/tests/coverage/clover.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/aine/tests/coverage/coverage-final.json b/aine/tests/coverage/coverage-final.json new file mode 100644 index 0000000..952c277 --- /dev/null +++ b/aine/tests/coverage/coverage-final.json @@ -0,0 +1,3 @@ +{"/Users/tomsej/Workspace/pi-ext/aine/tests/fixtures/setup-repo.ts": {"path":"/Users/tomsej/Workspace/pi-ext/aine/tests/fixtures/setup-repo.ts","statementMap":{"0":{"start":{"line":25,"column":2},"end":{"line":25,"column":null}},"1":{"start":{"line":33,"column":8},"end":{"line":33,"column":55}},"2":{"start":{"line":36,"column":2},"end":{"line":36,"column":null}},"3":{"start":{"line":37,"column":2},"end":{"line":37,"column":null}},"4":{"start":{"line":38,"column":2},"end":{"line":38,"column":null}},"5":{"start":{"line":41,"column":2},"end":{"line":41,"column":null}},"6":{"start":{"line":42,"column":2},"end":{"line":42,"column":null}},"7":{"start":{"line":43,"column":2},"end":{"line":46,"column":null}},"8":{"start":{"line":47,"column":2},"end":{"line":47,"column":null}},"9":{"start":{"line":48,"column":2},"end":{"line":48,"column":null}},"10":{"start":{"line":51,"column":2},"end":{"line":51,"column":null}},"11":{"start":{"line":52,"column":2},"end":{"line":52,"column":null}},"12":{"start":{"line":53,"column":2},"end":{"line":53,"column":null}},"13":{"start":{"line":55,"column":2},"end":{"line":62,"column":null}},"14":{"start":{"line":58,"column":6},"end":{"line":60,"column":null}},"15":{"start":{"line":59,"column":8},"end":{"line":59,"column":null}},"16":{"start":{"line":69,"column":15},"end":{"line":69,"column":34}},"17":{"start":{"line":71,"column":2},"end":{"line":71,"column":null}},"18":{"start":{"line":72,"column":2},"end":{"line":72,"column":null}},"19":{"start":{"line":79,"column":8},"end":{"line":79,"column":55}},"20":{"start":{"line":81,"column":2},"end":{"line":81,"column":null}},"21":{"start":{"line":82,"column":2},"end":{"line":82,"column":null}},"22":{"start":{"line":83,"column":2},"end":{"line":83,"column":null}},"23":{"start":{"line":86,"column":2},"end":{"line":86,"column":null}},"24":{"start":{"line":87,"column":2},"end":{"line":87,"column":null}},"25":{"start":{"line":88,"column":2},"end":{"line":88,"column":null}},"26":{"start":{"line":89,"column":2},"end":{"line":89,"column":null}},"27":{"start":{"line":92,"column":2},"end":{"line":92,"column":null}},"28":{"start":{"line":93,"column":2},"end":{"line":93,"column":null}},"29":{"start":{"line":94,"column":2},"end":{"line":94,"column":null}},"30":{"start":{"line":95,"column":2},"end":{"line":95,"column":null}},"31":{"start":{"line":97,"column":2},"end":{"line":104,"column":null}},"32":{"start":{"line":100,"column":6},"end":{"line":102,"column":null}},"33":{"start":{"line":101,"column":8},"end":{"line":101,"column":null}}},"fnMap":{"0":{"name":"git","decl":{"start":{"line":24,"column":9},"end":{"line":24,"column":13}},"loc":{"start":{"line":24,"column":42},"end":{"line":25,"column":null}},"line":24},"1":{"name":"createFixtureRepo","decl":{"start":{"line":32,"column":16},"end":{"line":32,"column":49}},"loc":{"start":{"line":32,"column":49},"end":{"line":62,"column":null}},"line":32},"2":{"name":"(anonymous_2)","decl":{"start":{"line":57,"column":4},"end":{"line":57,"column":19}},"loc":{"start":{"line":57,"column":19},"end":{"line":60,"column":null}},"line":57},"3":{"name":"createStagedFixtureRepo","decl":{"start":{"line":68,"column":16},"end":{"line":68,"column":55}},"loc":{"start":{"line":68,"column":55},"end":{"line":72,"column":null}},"line":68},"4":{"name":"createTwoCommitRepo","decl":{"start":{"line":78,"column":16},"end":{"line":78,"column":51}},"loc":{"start":{"line":78,"column":51},"end":{"line":104,"column":null}},"line":78},"5":{"name":"(anonymous_5)","decl":{"start":{"line":99,"column":4},"end":{"line":99,"column":19}},"loc":{"start":{"line":99,"column":19},"end":{"line":102,"column":null}},"line":99}},"branchMap":{},"s":{"0":27,"1":4,"2":4,"3":4,"4":4,"5":4,"6":4,"7":4,"8":4,"9":4,"10":4,"11":4,"12":4,"13":4,"14":1,"15":1,"16":0,"17":0,"18":0,"19":1,"20":1,"21":1,"22":1,"23":1,"24":1,"25":1,"26":1,"27":1,"28":1,"29":1,"30":1,"31":1,"32":1,"33":1},"f":{"0":27,"1":4,"2":1,"3":0,"4":1,"5":1},"b":{},"meta":{"lastBranch":0,"lastFunction":6,"lastStatement":34,"seen":{"f:24:9:24:13":0,"s:25:2:25:Infinity":0,"f:32:16:32:49":1,"s:33:8:33:55":1,"s:36:2:36:Infinity":2,"s:37:2:37:Infinity":3,"s:38:2:38:Infinity":4,"s:41:2:41:Infinity":5,"s:42:2:42:Infinity":6,"s:43:2:46:Infinity":7,"s:47:2:47:Infinity":8,"s:48:2:48:Infinity":9,"s:51:2:51:Infinity":10,"s:52:2:52:Infinity":11,"s:53:2:53:Infinity":12,"s:55:2:62:Infinity":13,"f:57:4:57:19":2,"s:58:6:60:Infinity":14,"s:59:8:59:Infinity":15,"f:68:16:68:55":3,"s:69:15:69:34":16,"s:71:2:71:Infinity":17,"s:72:2:72:Infinity":18,"f:78:16:78:51":4,"s:79:8:79:55":19,"s:81:2:81:Infinity":20,"s:82:2:82:Infinity":21,"s:83:2:83:Infinity":22,"s:86:2:86:Infinity":23,"s:87:2:87:Infinity":24,"s:88:2:88:Infinity":25,"s:89:2:89:Infinity":26,"s:92:2:92:Infinity":27,"s:93:2:93:Infinity":28,"s:94:2:94:Infinity":29,"s:95:2:95:Infinity":30,"s:97:2:104:Infinity":31,"f:99:4:99:19":5,"s:100:6:102:Infinity":32,"s:101:8:101:Infinity":33}}} +,"/Users/tomsej/Workspace/pi-ext/aine/tests/fixtures/test-server.ts": {"path":"/Users/tomsej/Workspace/pi-ext/aine/tests/fixtures/test-server.ts","statementMap":{"0":{"start":{"line":11,"column":6},"end":{"line":11,"column":56}},"1":{"start":{"line":22,"column":2},"end":{"line":22,"column":null}},"2":{"start":{"line":32,"column":15},"end":{"line":32,"column":28}},"3":{"start":{"line":33,"column":15},"end":{"line":33,"column":27}},"4":{"start":{"line":34,"column":15},"end":{"line":34,"column":null}},"5":{"start":{"line":36,"column":8},"end":{"line":40,"column":4}},"6":{"start":{"line":43,"column":15},"end":{"line":43,"column":null}},"7":{"start":{"line":44,"column":2},"end":{"line":46,"column":null}},"8":{"start":{"line":45,"column":4},"end":{"line":45,"column":null}},"9":{"start":{"line":49,"column":18},"end":{"line":49,"column":null}},"10":{"start":{"line":50,"column":16},"end":{"line":50,"column":26}},"11":{"start":{"line":51,"column":2},"end":{"line":58,"column":null}},"12":{"start":{"line":52,"column":4},"end":{"line":55,"column":null}},"13":{"start":{"line":53,"column":16},"end":{"line":53,"column":47}},"14":{"start":{"line":54,"column":6},"end":{"line":54,"column":null}},"15":{"start":{"line":54,"column":16},"end":{"line":54,"column":null}},"16":{"start":{"line":58,"column":4},"end":{"line":58,"column":null}},"17":{"start":{"line":58,"column":29},"end":{"line":58,"column":47}},"18":{"start":{"line":62,"column":2},"end":{"line":68,"column":null}},"19":{"start":{"line":63,"column":14},"end":{"line":63,"column":45}},"20":{"start":{"line":64,"column":4},"end":{"line":64,"column":null}},"21":{"start":{"line":64,"column":15},"end":{"line":64,"column":null}},"22":{"start":{"line":66,"column":4},"end":{"line":66,"column":null}},"23":{"start":{"line":67,"column":4},"end":{"line":67,"column":null}},"24":{"start":{"line":68,"column":4},"end":{"line":68,"column":null}},"25":{"start":{"line":71,"column":8},"end":{"line":74,"column":null}},"26":{"start":{"line":72,"column":4},"end":{"line":72,"column":null}},"27":{"start":{"line":72,"column":10},"end":{"line":72,"column":31}},"28":{"start":{"line":74,"column":4},"end":{"line":74,"column":null}},"29":{"start":{"line":74,"column":21},"end":{"line":74,"column":35}},"30":{"start":{"line":77,"column":2},"end":{"line":77,"column":null}},"31":{"start":{"line":82,"column":12},"end":{"line":82,"column":47}},"32":{"start":{"line":83,"column":15},"end":{"line":83,"column":29}},"33":{"start":{"line":84,"column":2},"end":{"line":84,"column":null}},"34":{"start":{"line":89,"column":2},"end":{"line":93,"column":null}},"35":{"start":{"line":98,"column":2},"end":{"line":102,"column":null}},"36":{"start":{"line":107,"column":2},"end":{"line":107,"column":null}}},"fnMap":{"0":{"name":"randomPort","decl":{"start":{"line":21,"column":9},"end":{"line":21,"column":30}},"loc":{"start":{"line":21,"column":30},"end":{"line":22,"column":null}},"line":21},"1":{"name":"startTestServer","decl":{"start":{"line":29,"column":22},"end":{"line":29,"column":null}},"loc":{"start":{"line":31,"column":23},"end":{"line":77,"column":null}},"line":31},"2":{"name":"(anonymous_2)","decl":{"start":{"line":44,"column":18},"end":{"line":44,"column":27}},"loc":{"start":{"line":44,"column":45},"end":{"line":46,"column":null}},"line":44},"3":{"name":"(anonymous_3)","decl":{"start":{"line":58,"column":14},"end":{"line":58,"column":23}},"loc":{"start":{"line":58,"column":29},"end":{"line":58,"column":47}},"line":58},"4":{"name":"(anonymous_4)","decl":{"start":{"line":71,"column":8},"end":{"line":71,"column":24}},"loc":{"start":{"line":71,"column":24},"end":{"line":74,"column":null}},"line":71},"5":{"name":"(anonymous_5)","decl":{"start":{"line":74,"column":4},"end":{"line":74,"column":21}},"loc":{"start":{"line":74,"column":21},"end":{"line":74,"column":35}},"line":74},"6":{"name":"api","decl":{"start":{"line":81,"column":22},"end":{"line":81,"column":35}},"loc":{"start":{"line":81,"column":121},"end":{"line":84,"column":null}},"line":81},"7":{"name":"apiPost","decl":{"start":{"line":88,"column":22},"end":{"line":88,"column":39}},"loc":{"start":{"line":88,"column":120},"end":{"line":93,"column":null}},"line":88},"8":{"name":"apiPatch","decl":{"start":{"line":97,"column":22},"end":{"line":97,"column":40}},"loc":{"start":{"line":97,"column":121},"end":{"line":102,"column":null}},"line":97},"9":{"name":"apiDelete","decl":{"start":{"line":106,"column":22},"end":{"line":106,"column":41}},"loc":{"start":{"line":106,"column":107},"end":{"line":107,"column":null}},"line":106}},"branchMap":{"0":{"loc":{"start":{"line":30,"column":2},"end":{"line":30,"column":null}},"type":"default-arg","locations":[{"start":{"line":30,"column":35},"end":{"line":30,"column":null}}],"line":30},"1":{"loc":{"start":{"line":54,"column":6},"end":{"line":54,"column":null}},"type":"if","locations":[{"start":{"line":54,"column":6},"end":{"line":54,"column":null}},{"start":{},"end":{}}],"line":54},"2":{"loc":{"start":{"line":64,"column":4},"end":{"line":64,"column":null}},"type":"if","locations":[{"start":{"line":64,"column":4},"end":{"line":64,"column":null}},{"start":{},"end":{}}],"line":64}},"s":{"0":3,"1":3,"2":3,"3":3,"4":3,"5":3,"6":3,"7":3,"8":0,"9":3,"10":3,"11":3,"12":9,"13":9,"14":3,"15":3,"16":6,"17":6,"18":3,"19":3,"20":3,"21":0,"22":0,"23":0,"24":0,"25":3,"26":3,"27":3,"28":3,"29":0,"30":3,"31":40,"32":40,"33":40,"34":9,"35":3,"36":2},"f":{"0":3,"1":3,"2":0,"3":6,"4":3,"5":0,"6":40,"7":9,"8":3,"9":2},"b":{"0":[3],"1":[3,0],"2":[0,3]},"meta":{"lastBranch":3,"lastFunction":10,"lastStatement":37,"seen":{"s:11:6:11:56":0,"f:21:9:21:30":0,"s:22:2:22:Infinity":1,"f:29:22:29:Infinity":1,"b:30:35:30:Infinity":0,"s:32:15:32:28":2,"s:33:15:33:27":3,"s:34:15:34:Infinity":4,"s:36:8:40:4":5,"s:43:15:43:Infinity":6,"s:44:2:46:Infinity":7,"f:44:18:44:27":2,"s:45:4:45:Infinity":8,"s:49:18:49:Infinity":9,"s:50:16:50:26":10,"s:51:2:58:Infinity":11,"s:52:4:55:Infinity":12,"s:53:16:53:47":13,"b:54:6:54:Infinity:undefined:undefined:undefined:undefined":1,"s:54:6:54:Infinity":14,"s:54:16:54:Infinity":15,"s:58:4:58:Infinity":16,"f:58:14:58:23":3,"s:58:29:58:47":17,"s:62:2:68:Infinity":18,"s:63:14:63:45":19,"b:64:4:64:Infinity:undefined:undefined:undefined:undefined":2,"s:64:4:64:Infinity":20,"s:64:15:64:Infinity":21,"s:66:4:66:Infinity":22,"s:67:4:67:Infinity":23,"s:68:4:68:Infinity":24,"s:71:8:74:Infinity":25,"f:71:8:71:24":4,"s:72:4:72:Infinity":26,"s:72:10:72:31":27,"s:74:4:74:Infinity":28,"f:74:4:74:21":5,"s:74:21:74:35":29,"s:77:2:77:Infinity":30,"f:81:22:81:35":6,"s:82:12:82:47":31,"s:83:15:83:29":32,"s:84:2:84:Infinity":33,"f:88:22:88:39":7,"s:89:2:93:Infinity":34,"f:97:22:97:40":8,"s:98:2:102:Infinity":35,"f:106:22:106:41":9,"s:107:2:107:Infinity":36}}} +} diff --git a/aine/tests/coverage/favicon.png b/aine/tests/coverage/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c1525b811a167671e9de1fa78aab9f5c0b61cef7 GIT binary patch literal 445 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4kiW$h6xih%orFLTRdGHLn;`zo@efna1>#C zP+yQ1a@(xwR@vcOg|93^@4RTb&rJ?ebaY4}0YgS)l{}^%au- z_;}9Zak48mJg9F`WyPkDf8Sd#=J(R3x$KX>%X`|@oLYbHga7uVXX@u){?wjyMnO+1 zTuHI+XIsFTuRkt*ZISfi{Q1E1(+AH@-!uMvG(H%&t*H1$^$z2LHOBP|-?Q^PxVHDn zE8Sgx8|3U1=T6uDT=Zi0;l^IynQwJtgb(g2vAt1la&Mp7X_vDT_Q)j8Yy0|>yZ&F7 z>gzqTLa*+5sbwwg_2_Z>x$}aP)2jaN%AU;Neb~!aH0t4llQ(#3f3m3>T?$;c<;CUD z*(?#4ax%qTk1L*g*`0EZDPVC|MvOnx2~Y9;F|DGdfE4vw1A9SNBas(w7%RP$}py8DW05-uwO!uq6UDXGquW&3{VYqqD7g#7*w zi_^dSQSIK!D!Gi|=%q_%GNyQ3 + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 87.32% + Statements + 62/71 +
+ + +
+ 60% + Branches + 3/5 +
+ + +
+ 81.25% + Functions + 13/16 +
+ + +
+ 89.39% + Lines + 59/66 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
setup-repo.ts +
+
91.17%31/34100%0/083.33%5/691.17%31/34
test-server.ts +
+
83.78%31/3760%3/580%8/1087.5%28/32
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/aine/tests/coverage/prettify.css b/aine/tests/coverage/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/aine/tests/coverage/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/aine/tests/coverage/prettify.js b/aine/tests/coverage/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/aine/tests/coverage/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/aine/tests/coverage/setup-repo.ts.html b/aine/tests/coverage/setup-repo.ts.html new file mode 100644 index 0000000..4046bd1 --- /dev/null +++ b/aine/tests/coverage/setup-repo.ts.html @@ -0,0 +1,400 @@ + + + + + + Code coverage report for setup-repo.ts + + + + + + + + + +
+
+

All files setup-repo.ts

+
+ +
+ 91.17% + Statements + 31/34 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 83.33% + Functions + 5/6 +
+ + +
+ 91.17% + Lines + 31/34 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +27x +  +  +  +  +  +  +  +4x +  +  +4x +4x +4x +  +  +4x +4x +4x +  +  +  +4x +4x +  +  +4x +4x +4x +  +4x +  +  +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +1x +1x +1x +  +  +1x +1x +1x +1x +  +  +1x +1x +1x +1x +  +1x +  +  +1x +1x +  +  +  +  + 
/**
+ * Creates a temporary git repo with known files for deterministic testing.
+ *
+ * Initial commit:
+ *   hello.ts  → "export const hello = 'world';\n"
+ *   readme.md → "# Test Repo\n\nThis is a test.\n"
+ *   utils.ts  → "export function add(a: number, b: number) { return a + b; }\n"
+ *
+ * Working tree changes (unstaged):
+ *   hello.ts  → modified ('world' → 'aine')
+ *   newfile.ts → added
+ *   utils.ts  → deleted
+ */
+import { mkdtempSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+ 
+export interface FixtureRepo {
+  dir: string;
+  cleanup: () => void;
+}
+ 
+function git(args: string[], cwd: string) {
+  execFileSync("git", args, { cwd, stdio: "pipe" });
+}
+ 
+/**
+ * Create a fixture repo with an initial commit and working tree changes.
+ * Returns the repo directory and a cleanup function.
+ */
+export function createFixtureRepo(): FixtureRepo {
+  const dir = mkdtempSync(join(tmpdir(), "aine-test-"));
+ 
+  // Init repo
+  git(["init"], dir);
+  git(["config", "user.email", "test@test.com"], dir);
+  git(["config", "user.name", "Test"], dir);
+ 
+  // Initial commit with 3 files
+  writeFileSync(join(dir, "hello.ts"), "export const hello = 'world';\n");
+  writeFileSync(join(dir, "readme.md"), "# Test Repo\n\nThis is a test.\n");
+  writeFileSync(
+    join(dir, "utils.ts"),
+    "export function add(a: number, b: number) { return a + b; }\n",
+  );
+  git(["add", "."], dir);
+  git(["commit", "-m", "initial"], dir);
+ 
+  // Working tree changes (unstaged)
+  writeFileSync(join(dir, "hello.ts"), "export const hello = 'aine';\n");
+  writeFileSync(join(dir, "newfile.ts"), "export const newFile = true;\n");
+  unlinkSync(join(dir, "utils.ts"));
+ 
+  return {
+    dir,
+    cleanup: () => {
+      try {
+        rmSync(dir, { recursive: true, force: true });
+      } catch {}
+    },
+  };
+}
+ 
+/**
+ * Create a fixture repo with staged changes (for --staged tests).
+ */
+export function createStagedFixtureRepo(): FixtureRepo {
+  const repo = createFixtureRepo();
+  // Stage the hello.ts modification
+  git(["add", "hello.ts"], repo.dir);
+  return repo;
+}
+ 
+/**
+ * Create a fixture repo with two commits (for HEAD~1..HEAD tests).
+ */
+export function createTwoCommitRepo(): FixtureRepo {
+  const dir = mkdtempSync(join(tmpdir(), "aine-test-"));
+ 
+  git(["init"], dir);
+  git(["config", "user.email", "test@test.com"], dir);
+  git(["config", "user.name", "Test"], dir);
+ 
+  // First commit
+  writeFileSync(join(dir, "hello.ts"), "export const hello = 'world';\n");
+  writeFileSync(join(dir, "readme.md"), "# Test Repo\n");
+  git(["add", "."], dir);
+  git(["commit", "-m", "first"], dir);
+ 
+  // Second commit: modify hello.ts, add config.ts
+  writeFileSync(join(dir, "hello.ts"), "export const hello = 'aine';\nexport const version = 2;\n");
+  writeFileSync(join(dir, "config.ts"), "export default { debug: true };\n");
+  git(["add", "."], dir);
+  git(["commit", "-m", "second"], dir);
+ 
+  return {
+    dir,
+    cleanup: () => {
+      try {
+        rmSync(dir, { recursive: true, force: true });
+      } catch {}
+    },
+  };
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/aine/tests/coverage/sort-arrow-sprite.png b/aine/tests/coverage/sort-arrow-sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed68316eb3f65dec9063332d2f69bf3093bbfab GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0y~yU|?rpV36iuV_;y|;5OBmfq@~=)5S5Qf-$*3|3hQ4 zf`Qw!#U2L?e0W@$Vi_Zt#MxdR>HO#=WF|J7#c{m=56hexM(h6^Z(XQmpqg+jN#nrN tssE?!n)YuJ%U&k~fdV%9gAMEqevg;zSU<7i3IhWJgQu&X%Q~loCIA|(EZhJ9 literal 0 HcmV?d00001 diff --git a/aine/tests/coverage/sorter.js b/aine/tests/coverage/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/aine/tests/coverage/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/aine/tests/coverage/test-server.ts.html b/aine/tests/coverage/test-server.ts.html new file mode 100644 index 0000000..bd25b60 --- /dev/null +++ b/aine/tests/coverage/test-server.ts.html @@ -0,0 +1,409 @@ + + + + + + Code coverage report for test-server.ts + + + + + + + + + +
+
+

All files test-server.ts

+
+ +
+ 83.78% + Statements + 31/37 +
+ + +
+ 60% + Branches + 3/5 +
+ + +
+ 80% + Functions + 8/10 +
+ + +
+ 87.5% + Lines + 28/32 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +  +3x +3x +3x +  +3x +  +  +  +  +  +  +3x +3x +  +  +  +  +3x +3x +3x +9x +9x +3x +  +  +  +6x +  +  +  +3x +3x +3x +  +  +  +  +  +  +3x +3x +  +3x +  +  +3x +  +  +  +  +40x +40x +40x +  +  +  +  +9x +  +  +  +  +  +  +  +  +3x +  +  +  +  +  +  +  +  +2x +  + 
/**
+ * Test helper: starts a real Aine server against a fixture git repo.
+ *
+ * Uses Bun subprocess (since the server requires Bun runtime),
+ * waits for it to be ready, returns base URL + cleanup.
+ */
+import { spawn, type ChildProcess } from "node:child_process";
+import { join, resolve } from "node:path";
+import { createFixtureRepo, type FixtureRepo } from "./setup-repo.js";
+ 
+const SERVER_SCRIPT = resolve(__dirname, "../../cli.ts");
+ 
+export interface TestServer {
+  port: number;
+  base: string;
+  repo: FixtureRepo;
+  cleanup: () => void;
+}
+ 
+/** Find a random available port */
+function randomPort(): number {
+  return 10000 + Math.floor(Math.random() * 50000);
+}
+ 
+/**
+ * Start a test server. Spawns `bun cli.ts` with --no-open and --port.
+ * Waits until the server responds to /api/info.
+ */
+export async function startTestServer(
+  repoFactory: () => FixtureRepo = createFixtureRepo,
+): Promise<TestServer> {
+  const repo = repoFactory();
+  const port = randomPort();
+  const base = `http://localhost:${port}`;
+ 
+  const proc = spawn("bun", ["run", SERVER_SCRIPT, "--no-open", "--port", String(port)], {
+    cwd: repo.dir,
+    stdio: ["pipe", "pipe", "pipe"],
+    env: { ...process.env, NODE_ENV: "test" },
+  });
+ 
+  // Collect stderr for debugging
+  let stderr = "";
+  proc.stderr?.on("data", (chunk: Buffer) => {
+    stderr += chunk.toString();
+  });
+ 
+  // Wait for server to be ready (poll /api/info)
+  const maxWait = 10_000;
+  const start = Date.now();
+  while (Date.now() - start < maxWait) {
+    try {
+      const r = await fetch(`${base}/api/info`);
+      Eif (r.ok) break;
+    } catch {
+      // Not ready yet
+    }
+    await new Promise((r) => setTimeout(r, 200));
+  }
+ 
+  // Verify it's actually up
+  try {
+    const r = await fetch(`${base}/api/info`);
+    Iif (!r.ok) throw new Error(`Server not ready: ${r.status}`);
+  } catch (e) {
+    proc.kill();
+    repo.cleanup();
+    throw new Error(`Server failed to start on port ${port}. stderr: ${stderr}\n${e}`);
+  }
+ 
+  const cleanup = () => {
+    try { proc.kill(); } catch {}
+    // Give process time to die before cleaning up repo
+    setTimeout(() => repo.cleanup(), 500);
+  };
+ 
+  return { port, base, repo, cleanup };
+}
+ 
+/** Simple fetch helper that returns parsed JSON */
+export async function api<T = any>(base: string, path: string, init?: RequestInit): Promise<{ status: number; data: T }> {
+  const r = await fetch(`${base}${path}`, init);
+  const data = await r.json() as T;
+  return { status: r.status, data };
+}
+ 
+/** POST JSON helper */
+export async function apiPost<T = any>(base: string, path: string, body: unknown): Promise<{ status: number; data: T }> {
+  return api<T>(base, path, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(body),
+  });
+}
+ 
+/** PATCH JSON helper */
+export async function apiPatch<T = any>(base: string, path: string, body: unknown): Promise<{ status: number; data: T }> {
+  return api<T>(base, path, {
+    method: "PATCH",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(body),
+  });
+}
+ 
+/** DELETE helper */
+export async function apiDelete<T = any>(base: string, path: string): Promise<{ status: number; data: T }> {
+  return api<T>(base, path, { method: "DELETE" });
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/aine/tests/e2e/diff-view.test.ts b/aine/tests/e2e/diff-view.test.ts new file mode 100644 index 0000000..8a58588 --- /dev/null +++ b/aine/tests/e2e/diff-view.test.ts @@ -0,0 +1,115 @@ +import { test, expect } from "@playwright/test"; +import { startE2EServer, type E2EServer } from "./helpers.js"; + +let server: E2EServer; + +test.beforeAll(async () => { + server = await startE2EServer(); +}); + +test.afterAll(() => { + server.cleanup(); +}); + +test("diff view renders HTML for a modified file", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + await page.click("text=hello.ts"); + await page.waitForTimeout(2000); + + // Diff is rendered inside a shadow DOM, so we need evaluate to read it + const shadowText = await page.evaluate(() => { + const host = document.querySelector("[id*='diff'] div[style]") + || document.querySelector("#diff-view div"); + if (!host?.shadowRoot) return document.body.textContent || ""; + return host.shadowRoot.textContent || ""; + }); + const allText = (await page.textContent("body")) + " " + shadowText; + expect(allText).toContain("hello"); +}); + +test("diff view renders added file", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + await page.click("text=newfile.ts"); + await page.waitForTimeout(2000); + + // Content is in shadow DOM + const shadowText = await page.evaluate(() => { + const hosts = document.querySelectorAll("div"); + for (const el of hosts) { + if (el.shadowRoot?.textContent?.includes("newFile")) { + return el.shadowRoot.textContent; + } + } + return ""; + }); + expect(shadowText).toContain("newFile"); +}); + +test("diff view renders deleted file", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + await page.click("text=utils.ts"); + await page.waitForTimeout(1500); + + const body = await page.textContent("body"); + expect(body).toContain("add"); + expect(body).toContain("number"); +}); + +test("split view has two panes", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + await page.click("text=hello.ts"); + await page.waitForTimeout(1500); + + // @pierre/diffs split view renders into a container with two sides + // Check that the diff container has content (indicating rendering worked) + const diffArea = page.locator("[class*='diff']").first(); + if (await diffArea.count() > 0) { + const html = await diffArea.innerHTML(); + expect(html.length).toBeGreaterThan(100); + } +}); + +test("switching to unified view changes layout", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + await page.click("text=hello.ts"); + await page.waitForTimeout(1500); + + // Get initial HTML + const beforeHtml = await page.content(); + + // Switch to unified + await page.keyboard.press("s"); + await page.waitForTimeout(1500); + + // HTML should change (different diff layout) + const afterHtml = await page.content(); + // At minimum the page should still be functional + const text = await page.textContent("body"); + expect(text).toContain("hello"); +}); + +test("expand unchanged toggle works", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + await page.click("text=hello.ts"); + await page.waitForTimeout(1500); + + // Toggle expand unchanged with 'e' key + await page.keyboard.press("e"); + await page.waitForTimeout(1000); + + // Page should still work + const text = await page.textContent("body"); + expect(text).toContain("hello"); +}); diff --git a/aine/tests/e2e/helpers.ts b/aine/tests/e2e/helpers.ts new file mode 100644 index 0000000..e995142 --- /dev/null +++ b/aine/tests/e2e/helpers.ts @@ -0,0 +1,54 @@ +/** + * E2E test helpers: manages a real Aine server + fixture repo for Playwright tests. + */ +import { spawn, type ChildProcess } from "node:child_process"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createFixtureRepo, + type FixtureRepo, +} from "../fixtures/setup-repo.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI = resolve(__dirname, "../../cli.ts"); + +export interface E2EServer { + port: number; + url: string; + repo: FixtureRepo; + proc: ChildProcess; + cleanup: () => void; +} + +function randomPort(): number { + return 10000 + Math.floor(Math.random() * 50000); +} + +export async function startE2EServer(): Promise { + const repo = createFixtureRepo(); + const port = randomPort(); + const url = `http://localhost:${port}`; + + const proc = spawn("bun", ["run", CLI, "--no-open", "--port", String(port)], { + cwd: repo.dir, + stdio: ["pipe", "pipe", "pipe"], + }); + + // Wait for server + const maxWait = 15_000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + try { + const r = await fetch(`${url}/api/info`); + if (r.ok) break; + } catch {} + await new Promise((r) => setTimeout(r, 200)); + } + + const cleanup = () => { + try { proc.kill(); } catch {} + setTimeout(() => repo.cleanup(), 500); + }; + + return { port, url, repo, proc, cleanup }; +} diff --git a/aine/tests/e2e/navigation.test.ts b/aine/tests/e2e/navigation.test.ts new file mode 100644 index 0000000..f156a1d --- /dev/null +++ b/aine/tests/e2e/navigation.test.ts @@ -0,0 +1,123 @@ +import { test, expect } from "@playwright/test"; +import { startE2EServer, type E2EServer } from "./helpers.js"; + +let server: E2EServer; + +test.beforeAll(async () => { + server = await startE2EServer(); +}); + +test.afterAll(() => { + server.cleanup(); +}); + +test("page loads and shows sidebar with files", async ({ page }) => { + await page.goto(server.url); + + // Wait for sidebar to populate with file entries + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + // Should see our 3 fixture files + const text = await page.textContent("body"); + expect(text).toContain("hello.ts"); + expect(text).toContain("newfile.ts"); + expect(text).toContain("utils.ts"); +}); + +test("clicking a file shows its diff", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + // Click on hello.ts in sidebar + await page.click("text=hello.ts"); + + // Wait for diff to render – should show the file content + await page.waitForTimeout(1000); + const body = await page.textContent("body"); + // The diff should contain the old or new value + expect(body).toMatch(/world|aine/); +}); + +test("keyboard j/k navigates between files", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + // Wait for initial load + await page.waitForTimeout(500); + + // Press j to go to next file + await page.keyboard.press("j"); + await page.waitForTimeout(300); + + // Press k to go to previous file + await page.keyboard.press("k"); + await page.waitForTimeout(300); + + // Page should still be functional (no crash) + const text = await page.textContent("body"); + expect(text).toContain("hello.ts"); +}); + +test("? key toggles help overlay", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + // Open help + await page.keyboard.press("?"); + await page.waitForTimeout(300); + + // Help overlay should be visible with keyboard shortcuts + const body = await page.textContent("body"); + expect(body).toMatch(/keyboard|shortcuts|help/i); + + // Close help with Escape + await page.keyboard.press("Escape"); + await page.waitForTimeout(300); +}); + +test("s key toggles split/unified view", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + // Click a file first to have a diff visible + await page.click("text=hello.ts"); + await page.waitForTimeout(1000); + + // Toggle to unified + await page.keyboard.press("s"); + await page.waitForTimeout(500); + + // Toggle back to split + await page.keyboard.press("s"); + await page.waitForTimeout(500); + + // Page should still work + const text = await page.textContent("body"); + expect(text).toContain("hello.ts"); +}); + +test("b key toggles sidebar visibility", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + // Sidebar should be visible initially + const sidebar = page.locator("#sidebar").first(); + await expect(sidebar).toBeVisible(); + + // Toggle sidebar off + await page.keyboard.press("b"); + await page.waitForTimeout(300); + + // Toggle sidebar back on + await page.keyboard.press("b"); + await page.waitForTimeout(300); +}); + +test("toolbar shows repo info", async ({ page }) => { + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + const body = await page.textContent("body"); + // Should show file count or repo name + expect(body).toMatch(/3\s*(files?|changed)/i); +}); diff --git a/aine/tests/e2e/threads.test.ts b/aine/tests/e2e/threads.test.ts new file mode 100644 index 0000000..56821f6 --- /dev/null +++ b/aine/tests/e2e/threads.test.ts @@ -0,0 +1,134 @@ +import { test, expect } from "@playwright/test"; +import { startE2EServer, type E2EServer } from "./helpers.js"; + +let server: E2EServer; + +test.beforeAll(async () => { + server = await startE2EServer(); +}); + +test.afterAll(() => { + server.cleanup(); +}); + +test("can create a thread via API and see it in UI", async ({ page }) => { + // Create thread via API first + await fetch(`${server.url}/api/threads`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: "hello.ts", + startLine: 1, + side: "new", + body: "E2E test comment", + severity: "nit", + }), + }); + + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + + // Select the file with the thread + await page.click("text=hello.ts"); + await page.waitForTimeout(1500); + + // The comment should appear somewhere on the page + const body = await page.textContent("body"); + expect(body).toContain("E2E test comment"); +}); + +test("thread count shows in sidebar or toolbar", async ({ page }) => { + // Create a thread + await fetch(`${server.url}/api/threads`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: "hello.ts", + startLine: 1, + side: "new", + body: "Count test comment", + }), + }); + + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + await page.waitForTimeout(500); + + // The page should reflect that there are threads + // (exact UI depends on implementation - just verify no crash) + const text = await page.textContent("body"); + expect(text).toBeDefined(); +}); + +test("resolving a thread via API updates UI", async ({ page }) => { + // Create a thread + const createRes = await fetch(`${server.url}/api/threads`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: "hello.ts", + startLine: 1, + side: "new", + body: "Thread to resolve", + }), + }); + const thread = await createRes.json(); + + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + await page.click("text=hello.ts"); + await page.waitForTimeout(1500); + + // Resolve via API + await fetch(`${server.url}/api/threads/${thread.id}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "resolved" }), + }); + + // Reload page to see update + await page.reload(); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + await page.click("text=hello.ts"); + await page.waitForTimeout(1500); + + // Page should still work (thread should show as resolved or be filtered) + const body = await page.textContent("body"); + expect(body).toBeDefined(); +}); + +test("deleting a thread via API removes it from UI", async ({ page }) => { + // Create a thread + const createRes = await fetch(`${server.url}/api/threads`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + file: "hello.ts", + startLine: 1, + side: "new", + body: "Thread to delete - unique marker", + }), + }); + const thread = await createRes.json(); + + await page.goto(server.url); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + await page.click("text=hello.ts"); + await page.waitForTimeout(1500); + + // Verify it's visible + let body = await page.textContent("body"); + expect(body).toContain("Thread to delete - unique marker"); + + // Delete via API + await fetch(`${server.url}/api/threads/${thread.id}`, { method: "DELETE" }); + + // Reload to see update + await page.reload(); + await page.waitForSelector("#sidebar", { timeout: 10_000 }); + await page.click("text=hello.ts"); + await page.waitForTimeout(1500); + + body = await page.textContent("body"); + expect(body).not.toContain("Thread to delete - unique marker"); +}); diff --git a/aine/tests/fixtures/setup-repo.ts b/aine/tests/fixtures/setup-repo.ts new file mode 100644 index 0000000..818a9b1 --- /dev/null +++ b/aine/tests/fixtures/setup-repo.ts @@ -0,0 +1,105 @@ +/** + * Creates a temporary git repo with known files for deterministic testing. + * + * Initial commit: + * hello.ts → "export const hello = 'world';\n" + * readme.md → "# Test Repo\n\nThis is a test.\n" + * utils.ts → "export function add(a: number, b: number) { return a + b; }\n" + * + * Working tree changes (unstaged): + * hello.ts → modified ('world' → 'aine') + * newfile.ts → added + * utils.ts → deleted + */ +import { mkdtempSync, writeFileSync, unlinkSync, rmSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +export interface FixtureRepo { + dir: string; + cleanup: () => void; +} + +function git(args: string[], cwd: string) { + execFileSync("git", args, { cwd, stdio: "pipe" }); +} + +/** + * Create a fixture repo with an initial commit and working tree changes. + * Returns the repo directory and a cleanup function. + */ +export function createFixtureRepo(): FixtureRepo { + const dir = mkdtempSync(join(tmpdir(), "aine-test-")); + + // Init repo + git(["init"], dir); + git(["config", "user.email", "test@test.com"], dir); + git(["config", "user.name", "Test"], dir); + + // Initial commit with 3 files + writeFileSync(join(dir, "hello.ts"), "export const hello = 'world';\n"); + writeFileSync(join(dir, "readme.md"), "# Test Repo\n\nThis is a test.\n"); + writeFileSync( + join(dir, "utils.ts"), + "export function add(a: number, b: number) { return a + b; }\n", + ); + git(["add", "."], dir); + git(["commit", "-m", "initial"], dir); + + // Working tree changes (unstaged) + writeFileSync(join(dir, "hello.ts"), "export const hello = 'aine';\n"); + writeFileSync(join(dir, "newfile.ts"), "export const newFile = true;\n"); + unlinkSync(join(dir, "utils.ts")); + + return { + dir, + cleanup: () => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch {} + }, + }; +} + +/** + * Create a fixture repo with staged changes (for --staged tests). + */ +export function createStagedFixtureRepo(): FixtureRepo { + const repo = createFixtureRepo(); + // Stage the hello.ts modification + git(["add", "hello.ts"], repo.dir); + return repo; +} + +/** + * Create a fixture repo with two commits (for HEAD~1..HEAD tests). + */ +export function createTwoCommitRepo(): FixtureRepo { + const dir = mkdtempSync(join(tmpdir(), "aine-test-")); + + git(["init"], dir); + git(["config", "user.email", "test@test.com"], dir); + git(["config", "user.name", "Test"], dir); + + // First commit + writeFileSync(join(dir, "hello.ts"), "export const hello = 'world';\n"); + writeFileSync(join(dir, "readme.md"), "# Test Repo\n"); + git(["add", "."], dir); + git(["commit", "-m", "first"], dir); + + // Second commit: modify hello.ts, add config.ts + writeFileSync(join(dir, "hello.ts"), "export const hello = 'aine';\nexport const version = 2;\n"); + writeFileSync(join(dir, "config.ts"), "export default { debug: true };\n"); + git(["add", "."], dir); + git(["commit", "-m", "second"], dir); + + return { + dir, + cleanup: () => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch {} + }, + }; +} diff --git a/aine/tests/fixtures/test-server.ts b/aine/tests/fixtures/test-server.ts new file mode 100644 index 0000000..448382d --- /dev/null +++ b/aine/tests/fixtures/test-server.ts @@ -0,0 +1,108 @@ +/** + * Test helper: starts a real Aine server against a fixture git repo. + * + * Uses Bun subprocess (since the server requires Bun runtime), + * waits for it to be ready, returns base URL + cleanup. + */ +import { spawn, type ChildProcess } from "node:child_process"; +import { join, resolve } from "node:path"; +import { createFixtureRepo, type FixtureRepo } from "./setup-repo.js"; + +const SERVER_SCRIPT = resolve(__dirname, "../../cli.ts"); + +export interface TestServer { + port: number; + base: string; + repo: FixtureRepo; + cleanup: () => void; +} + +/** Find a random available port */ +function randomPort(): number { + return 10000 + Math.floor(Math.random() * 50000); +} + +/** + * Start a test server. Spawns `bun cli.ts` with --no-open and --port. + * Waits until the server responds to /api/info. + */ +export async function startTestServer( + repoFactory: () => FixtureRepo = createFixtureRepo, +): Promise { + const repo = repoFactory(); + const port = randomPort(); + const base = `http://localhost:${port}`; + + const proc = spawn("bun", ["run", SERVER_SCRIPT, "--no-open", "--port", String(port)], { + cwd: repo.dir, + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, NODE_ENV: "test" }, + }); + + // Collect stderr for debugging + let stderr = ""; + proc.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + // Wait for server to be ready (poll /api/info) + const maxWait = 10_000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + try { + const r = await fetch(`${base}/api/info`); + if (r.ok) break; + } catch { + // Not ready yet + } + await new Promise((r) => setTimeout(r, 200)); + } + + // Verify it's actually up + try { + const r = await fetch(`${base}/api/info`); + if (!r.ok) throw new Error(`Server not ready: ${r.status}`); + } catch (e) { + proc.kill(); + repo.cleanup(); + throw new Error(`Server failed to start on port ${port}. stderr: ${stderr}\n${e}`); + } + + const cleanup = () => { + try { proc.kill(); } catch {} + // Give process time to die before cleaning up repo + setTimeout(() => repo.cleanup(), 500); + }; + + return { port, base, repo, cleanup }; +} + +/** Simple fetch helper that returns parsed JSON */ +export async function api(base: string, path: string, init?: RequestInit): Promise<{ status: number; data: T }> { + const r = await fetch(`${base}${path}`, init); + const data = await r.json() as T; + return { status: r.status, data }; +} + +/** POST JSON helper */ +export async function apiPost(base: string, path: string, body: unknown): Promise<{ status: number; data: T }> { + return api(base, path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** PATCH JSON helper */ +export async function apiPatch(base: string, path: string, body: unknown): Promise<{ status: number; data: T }> { + return api(base, path, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** DELETE helper */ +export async function apiDelete(base: string, path: string): Promise<{ status: number; data: T }> { + return api(base, path, { method: "DELETE" }); +} diff --git a/aine/tests/fixtures/vitest-setup.ts b/aine/tests/fixtures/vitest-setup.ts new file mode 100644 index 0000000..316ddc1 --- /dev/null +++ b/aine/tests/fixtures/vitest-setup.ts @@ -0,0 +1,16 @@ +/** + * Vitest setup: polyfill Bun globals that don't exist in Node. + */ +import { createHash } from "node:crypto"; + +if (typeof globalThis.Bun === "undefined") { + (globalThis as any).Bun = { + hash(input: string | Buffer, seed?: number): bigint { + const h = createHash("sha256"); + if (seed !== undefined) h.update(String(seed)); + h.update(typeof input === "string" ? input : Buffer.from(input)); + const hex = h.digest("hex").slice(0, 16); // 64-bit + return BigInt("0x" + hex); + }, + }; +} diff --git a/aine/tests/playwright.config.ts b/aine/tests/playwright.config.ts new file mode 100644 index 0000000..8737395 --- /dev/null +++ b/aine/tests/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + retries: 0, + workers: 1, // Serial – each test file starts its own server + use: { + headless: true, + viewport: { width: 1280, height: 720 }, + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], +}); diff --git a/aine/tests/unit/git.test.ts b/aine/tests/unit/git.test.ts new file mode 100644 index 0000000..3d5b8b9 --- /dev/null +++ b/aine/tests/unit/git.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { + createFixtureRepo, + createTwoCommitRepo, + type FixtureRepo, +} from "../fixtures/setup-repo.js"; +import { + isGitRepo, + getRepoName, + getCurrentBranch, + getChangedFiles, + getOldNewContents, + parseDiffRefs, + parseRefs, + contentHash, +} from "../../git.js"; + +describe("git helpers – pure functions", () => { + it("parseDiffRefs: no args → HEAD vs working tree", () => { + const r = parseDiffRefs([]); + expect(r).toEqual({ oldRef: "HEAD", newRef: null }); + }); + + it("parseDiffRefs: single ref", () => { + const r = parseDiffRefs(["main"]); + expect(r).toEqual({ oldRef: "main", newRef: null }); + }); + + it("parseDiffRefs: range ref", () => { + const r = parseDiffRefs(["main..feature"]); + expect(r).toEqual({ oldRef: "main", newRef: "feature" }); + }); + + it("parseDiffRefs: range with empty left", () => { + const r = parseDiffRefs(["..feature"]); + expect(r).toEqual({ oldRef: "HEAD", newRef: "feature" }); + }); + + it("contentHash: same input → same hash", () => { + const h1 = contentHash("hello", "world"); + const h2 = contentHash("hello", "world"); + expect(h1).toBe(h2); + }); + + it("contentHash: different input → different hash", () => { + const h1 = contentHash("hello", "world"); + const h2 = contentHash("hello", "other"); + expect(h1).not.toBe(h2); + }); + + it("contentHash: null handling", () => { + const h1 = contentHash(null, "world"); + const h2 = contentHash("hello", null); + const h3 = contentHash(null, null); + expect(h1).not.toBe(h2); + expect(h1).not.toBe(h3); + }); +}); + +describe("git helpers – fixture repo", () => { + let repo: FixtureRepo; + + beforeAll(() => { + repo = createFixtureRepo(); + }); + + afterAll(() => { + repo.cleanup(); + }); + + it("isGitRepo returns true for git repos", () => { + expect(isGitRepo(repo.dir)).toBe(true); + }); + + it("isGitRepo returns false for non-repos", () => { + expect(isGitRepo("/tmp")).toBe(false); + }); + + it("getRepoName returns directory name", () => { + const name = getRepoName(repo.dir); + expect(name).toMatch(/^aine-test-/); + }); + + it("getCurrentBranch returns a branch name", () => { + const branch = getCurrentBranch(repo.dir); + expect(typeof branch).toBe("string"); + expect(branch.length).toBeGreaterThan(0); + }); + + it("getChangedFiles: working tree shows 3 changes", () => { + const files = getChangedFiles([], repo.dir); + expect(files.length).toBe(3); + + const paths = files.map((f) => f.path).sort(); + expect(paths).toEqual(["hello.ts", "newfile.ts", "utils.ts"]); + + const hello = files.find((f) => f.path === "hello.ts")!; + expect(hello.status).toBe("modified"); + expect(hello.additions).toBeGreaterThan(0); + + const newfile = files.find((f) => f.path === "newfile.ts")!; + expect(newfile.status).toBe("added"); + expect(newfile.untracked).toBe(true); + + const utils = files.find((f) => f.path === "utils.ts")!; + expect(utils.status).toBe("deleted"); + }); + + it("getOldNewContents: modified file has both old and new", () => { + const files = getChangedFiles([], repo.dir); + const hello = files.find((f) => f.path === "hello.ts")!; + const { oldContent, newContent } = getOldNewContents(hello, [], repo.dir); + + expect(oldContent).toContain("world"); + expect(newContent).toContain("aine"); + }); + + it("getOldNewContents: added untracked file has null old", () => { + const files = getChangedFiles([], repo.dir); + const nf = files.find((f) => f.path === "newfile.ts")!; + const { oldContent, newContent } = getOldNewContents(nf, [], repo.dir); + + expect(oldContent).toBeNull(); + expect(newContent).toContain("newFile"); + }); + + it("getOldNewContents: deleted file has null new", () => { + const files = getChangedFiles([], repo.dir); + const utils = files.find((f) => f.path === "utils.ts")!; + const { oldContent, newContent } = getOldNewContents(utils, [], repo.dir); + + expect(oldContent).toContain("add"); + expect(newContent).toBeNull(); + }); + + it("parseRefs: valid ref", () => { + const r = parseRefs(["HEAD"], repo.dir); + expect("diffArgs" in r).toBe(true); + }); + + it("parseRefs: invalid ref returns error", () => { + const r = parseRefs(["nonexistent-branch-xyz"], repo.dir); + expect("error" in r).toBe(true); + }); +}); + +describe("git helpers – two-commit repo", () => { + let repo: FixtureRepo; + + beforeAll(() => { + repo = createTwoCommitRepo(); + }); + + afterAll(() => { + repo.cleanup(); + }); + + it("getChangedFiles with HEAD~1..HEAD shows inter-commit changes", () => { + const files = getChangedFiles(["HEAD~1..HEAD"], repo.dir); + expect(files.length).toBe(2); + + const paths = files.map((f) => f.path).sort(); + expect(paths).toEqual(["config.ts", "hello.ts"]); + }); + + it("getOldNewContents with ref range reads from git objects", () => { + const files = getChangedFiles(["HEAD~1..HEAD"], repo.dir); + const hello = files.find((f) => f.path === "hello.ts")!; + const { oldContent, newContent } = getOldNewContents( + hello, + ["HEAD~1..HEAD"], + repo.dir, + ); + + expect(oldContent).toContain("world"); + expect(newContent).toContain("aine"); + expect(newContent).toContain("version"); + }); +}); diff --git a/aine/tests/unit/lru-cache.test.ts b/aine/tests/unit/lru-cache.test.ts new file mode 100644 index 0000000..c1e677a --- /dev/null +++ b/aine/tests/unit/lru-cache.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { LRUCache } from "../../lru-cache.js"; + +describe("LRUCache", () => { + it("get/set basics", () => { + const c = new LRUCache(3); + c.set("a", 1); + c.set("b", 2); + expect(c.get("a")).toBe(1); + expect(c.get("b")).toBe(2); + expect(c.get("x")).toBeUndefined(); + expect(c.size).toBe(2); + }); + + it("evicts oldest when over capacity", () => { + const c = new LRUCache(3); + c.set("a", 1); + c.set("b", 2); + c.set("c", 3); + c.set("d", 4); // evicts "a" + expect(c.get("a")).toBeUndefined(); + expect(c.get("b")).toBe(2); + expect(c.get("d")).toBe(4); + expect(c.size).toBe(3); + }); + + it("get promotes to most-recently-used", () => { + const c = new LRUCache(3); + c.set("a", 1); + c.set("b", 2); + c.set("c", 3); + c.get("a"); // promote "a" + c.set("d", 4); // should evict "b" (oldest after promotion) + expect(c.get("a")).toBe(1); + expect(c.get("b")).toBeUndefined(); + expect(c.get("c")).toBe(3); + expect(c.get("d")).toBe(4); + }); + + it("set overwrites and promotes existing key", () => { + const c = new LRUCache(3); + c.set("a", 1); + c.set("b", 2); + c.set("c", 3); + c.set("a", 10); // overwrite + promote + c.set("d", 4); // evicts "b" + expect(c.get("a")).toBe(10); + expect(c.get("b")).toBeUndefined(); + }); + + it("has() works without promoting", () => { + const c = new LRUCache(2); + c.set("a", 1); + c.set("b", 2); + expect(c.has("a")).toBe(true); + expect(c.has("x")).toBe(false); + }); + + it("delete() removes entry", () => { + const c = new LRUCache(3); + c.set("a", 1); + c.set("b", 2); + expect(c.delete("a")).toBe(true); + expect(c.get("a")).toBeUndefined(); + expect(c.delete("x")).toBe(false); + expect(c.size).toBe(1); + }); + + it("deleteByPrefix() removes matching entries", () => { + const c = new LRUCache(10); + c.set("file:a", 1); + c.set("file:b", 2); + c.set("theme:x", 3); + const count = c.deleteByPrefix("file:"); + expect(count).toBe(2); + expect(c.size).toBe(1); + expect(c.get("theme:x")).toBe(3); + }); + + it("clear() empties cache", () => { + const c = new LRUCache(3); + c.set("a", 1); + c.set("b", 2); + c.clear(); + expect(c.size).toBe(0); + expect(c.get("a")).toBeUndefined(); + }); + + it("capacity of 1", () => { + const c = new LRUCache(1); + c.set("a", "x"); + c.set("b", "y"); + expect(c.get("a")).toBeUndefined(); + expect(c.get("b")).toBe("y"); + expect(c.size).toBe(1); + }); +}); diff --git a/aine/tests/vitest.config.ts b/aine/tests/vitest.config.ts new file mode 100644 index 0000000..8b0e8a2 --- /dev/null +++ b/aine/tests/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "node:path"; + +export default defineConfig({ + test: { + root: resolve(__dirname), + include: ["unit/**/*.test.ts", "api/**/*.test.ts"], + setupFiles: ["./fixtures/vitest-setup.ts"], + testTimeout: 15_000, + hookTimeout: 15_000, + }, +}); diff --git a/aine/theme.ts b/aine/theme.ts new file mode 100644 index 0000000..aae6018 --- /dev/null +++ b/aine/theme.ts @@ -0,0 +1,149 @@ +/** + * Theme loader for Aine. + * + * Theme search order: + * 1. .aine/themes/.json (per-repo) + * 2. ~/.config/aine/themes/.json (user custom) + * 3. /themes/.json (built-in) + * + * A theme file is a shiki-compatible JSON theme with an optional "aine" section + * for UI chrome colors. Minimal theme (just UI colors): + * + * { + * "name": "my-theme", + * "type": "dark", + * "aine": { + * "bg": "#1e1e2e", + * "sidebar": "#1e1e2e", + * "text": "#cdd6f4", + * "muted": "#7f849c", + * "accent": "#89b4fa", + * "border": "#313244", + * "add": "#a6e3a1", + * "delete": "#f38ba8", + * "modified": "#f9e2af" + * } + * } + * + * Full themes include shiki tokenColors for syntax highlighting. + * If no tokenColors are present, the built-in catppuccin-mocha is used as fallback. + */ + +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; +import type { ThemeColors } from "./types.js"; + + + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BUILTIN_THEMES_DIR = join(__dirname, "themes"); +const USER_THEMES_DIR = join(homedir(), ".config", "aine", "themes"); + +export interface LoadedTheme { + name: string; + type: "dark" | "light"; + /** Full shiki theme data (for SSR worker). */ + shikiTheme: Record; + /** UI chrome colors. */ + ui: ThemeColors; +} + +const DEFAULT_UI: ThemeColors = { + bg: "#15131e", + sidebar: "#15131e", + hover: "#2a2440", + active: "#45475a", + text: "#cdd6f4", + muted: "#7f849c", + accent: "#cba6f7", + border: "#313244", + add: "#a6e3a1", + delete: "#f38ba8", + modified: "#f9e2af", + addBg: "rgba(166, 227, 161, 0.07)", + deleteBg: "rgba(243, 139, 168, 0.07)", + addBorder: "rgba(166, 227, 161, 0.25)", + deleteBorder: "rgba(243, 139, 168, 0.25)", +}; + +function tryLoadJson(path: string): any | null { + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return null; + } +} + +/** + * Load a theme by name. Searches repo → user → built-in directories. + * Falls back to catppuccin-mocha if not found. + */ +export function loadTheme(name: string, repoRoot?: string): LoadedTheme { + const filename = name.endsWith(".json") ? name : `${name}.json`; + + // Search order + const candidates: string[] = []; + if (repoRoot) candidates.push(join(repoRoot, ".aine", "themes", filename)); + candidates.push(join(USER_THEMES_DIR, filename)); + candidates.push(join(BUILTIN_THEMES_DIR, filename)); + + let themeData: any = null; + for (const path of candidates) { + themeData = tryLoadJson(path); + if (themeData) break; + } + + // Fallback to built-in catppuccin + if (!themeData) { + themeData = tryLoadJson(join(BUILTIN_THEMES_DIR, "catppuccin-mocha.json")); + } + + if (!themeData) { + // Absolute fallback — return defaults with no shiki theme + return { + name: "catppuccin-mocha", + type: "dark", + shikiTheme: {}, + ui: { ...DEFAULT_UI }, + }; + } + + // Extract aine UI colors + const aineSection = themeData.aine || {}; + const ui: ThemeColors = { ...DEFAULT_UI }; + for (const key of Object.keys(DEFAULT_UI) as (keyof ThemeColors)[]) { + if (typeof aineSection[key] === "string") { + ui[key] = aineSection[key]; + } + } + + // Build shiki theme (everything except aine section) + const { aine: _, ...shikiTheme } = themeData; + + // If theme has no tokenColors (UI-only theme), load catppuccin as shiki base + if (!shikiTheme.tokenColors && !shikiTheme.settings) { + const base = tryLoadJson(join(BUILTIN_THEMES_DIR, "catppuccin-mocha.json")); + if (base) { + const { aine: __, ...baseShiki } = base; + shikiTheme.tokenColors = baseShiki.tokenColors; + shikiTheme.semanticHighlighting = baseShiki.semanticHighlighting; + shikiTheme.semanticTokenColors = baseShiki.semanticTokenColors; + if (!shikiTheme.colors) shikiTheme.colors = baseShiki.colors; + } + } + + // Override editor.background with aine.bg + if (!shikiTheme.colors) shikiTheme.colors = {}; + shikiTheme.colors["editor.background"] = ui.bg; + + return { + name: themeData.name || name, + type: themeData.type || "dark", + shikiTheme, + ui, + }; +} + + diff --git a/aine/themes/catppuccin-mocha.json b/aine/themes/catppuccin-mocha.json new file mode 100644 index 0000000..e117368 --- /dev/null +++ b/aine/themes/catppuccin-mocha.json @@ -0,0 +1,973 @@ +{ + "name": "Catppuccin Monokai Mocha", + "type": "dark", + "semanticHighlighting": true, + "semanticTokenColors": { + "enumMember": { + "foreground": "#cba6f7" + }, + "namespace": { + "foreground": "#cdd6f4" + }, + "concept": { + "foreground": "#f5e0dc" + } + }, + "tokenColors": [ + { + "name": "Comment", + "scope": [ + "comment", + "string.comment" + ], + "settings": { + "fontStyle": "italic", + "foreground": "#6c7086" + } + }, + { + "name": "String", + "scope": [ + "string", + "string.template" + ], + "settings": { + "foreground": "#f9e2af" + } + }, + { + "name": "Number", + "scope": "constant.numeric", + "settings": { + "foreground": "#cba6f7" + } + }, + { + "name": "Embedded String Begin/End", + "scope": [ + "string.embedded.begin", + "string.embedded.end", + "punctuation.definition.template-expression", + "punctuation.section.embedded" + ], + "settings": { + "foreground": "#cba6f7" + } + }, + { + "name": "Default Embedded Color", + "scope": [ + "punctuation.section.embedded.begin.js", + "punctuation.section.embedded.end.js", + "punctuation.section.embedded.begin.erb", + "punctuation.section.embedded.end.erb", + "source.elixir.embedded", + "punctuation.separator", + "punctuation.accessor", + "meta.brace" + ], + "settings": { + "foreground": "#cdd6f4" + } + }, + { + "name": "Built-in constant", + "scope": "constant.language", + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "User-defined constant", + "scope": [ + "constant.character", + "constant.other" + ], + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "Language Variable", + "scope": "variable.language", + "settings": { + "foreground": "#f38ba8" + } + }, + { + "name": "Keyword", + "scope": [ + "keyword", + "keyword.operator.logical", + "keyword.operator.constructor" + ], + "settings": { + "foreground": "#f38ba8" + } + }, + { + "name": "Keyword Operator", + "scope": "keyword.operator", + "settings": { + "fontStyle": "bold", + "foreground": "#f38ba8" + } + }, + { + "name": "Storage", + "scope": "storage", + "settings": { + "foreground": "#f38ba8" + } + }, + { + "name": "Types", + "scope": "storage.type", + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "Class", + "scope": [ + "entity.name.class", + "entity.name.module", + "entity.name.type", + "storage.identifier", + "support.class" + ], + "settings": { + "foreground": "#89b4fa" + } + }, + { + "name": "Variable Object", + "scope": [ + "variable.other.object", + "variable.other.constant", + "variable.other.global", + "variable.other.readwrite.class", + "variable.other.readwrite.instance", + "variable.other.readwrite.batchfile", + "variable.readwrite", + "variable.readwrite.other.block" + ], + "settings": { + "foreground": "#cdd6f4" + } + }, + { + "name": "Other variable", + "scope": [ + "variable.other", + "variable.other.property", + "variable.other.block" + ], + "settings": { + "foreground": "#cdd6f4" + } + }, + { + "name": "Inherited Class", + "scope": "entity.other.inherited-class", + "settings": { + "foreground": "#a6e3a1" + } + }, + { + "name": "Package Import", + "scope": [ + "storage.modifier.import", + "storage.modifier.package" + ], + "settings": { + "foreground": "#89b4fa" + } + }, + { + "name": "Function name", + "scope": [ + "entity.name.function", + "support.function" + ], + "settings": { + "foreground": "#a6e3a1" + } + }, + { + "name": "Function argument", + "scope": [ + "variable.parameter", + "entity.name.variable.parameter", + "parameter.variable" + ], + "settings": { + "fontStyle": "italic", + "foreground": "#fab387" + } + }, + { + "name": "Function call", + "scope": "entity.name.function-call", + "settings": { + "foreground": "#cdd6f4" + } + }, + { + "name": "Builtin Functions", + "scope": [ + "function.support.builtin", + "function.support.core" + ], + "settings": { + "foreground": "#a6e3a1" + } + }, + { + "name": "Tag Name", + "scope": [ + "entity.name.tag", + "entity.name.tag.class.js" + ], + "settings": { + "foreground": "#f38ba8" + } + }, + { + "name": "Tag Class and ID", + "scope": [ + "entity.name.tag.class", + "entity.name.tag.id" + ], + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "Tag Attribute", + "scope": "entity.other.attribute-name", + "settings": { + "foreground": "#a6e3a1" + } + }, + { + "name": "Library constant", + "scope": "support.constant", + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "Library class/type", + "scope": [ + "support.type", + "support.variable" + ], + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "Json Property", + "scope": "support.dictionary.json", + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "StyleSheet Property name", + "scope": [ + "support.type.property-name.css", + "support.type.property-name.scss", + "support.type.property-name.less", + "support.type.property-name.sass" + ], + "settings": { + "foreground": "#cdd6f4" + } + }, + { + "name": "CSS: Pseudo Attribute Names", + "scope": [ + "entity.other.attribute-name.pseudo-class.css", + "entity.other.attribute-name.pseudo-class.scss", + "entity.other.attribute-name.pseudo-class.less", + "entity.other.attribute-name.pseudo-class.sass", + "entity.other.attribute-name.pseudo-element.css", + "entity.other.attribute-name.pseudo-element.scss", + "entity.other.attribute-name.pseudo-element.less", + "entity.other.attribute-name.pseudo-element.sass" + ], + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "StyleSheet Property value", + "scope": [ + "support.constant.css", + "support.constant.scss", + "support.constant.less", + "support.constant.sass" + ], + "settings": { + "foreground": "#a6e3a1" + } + }, + { + "name": "StyleSheet Variable", + "scope": [ + "variable.css", + "variable.scss", + "variable.less", + "variable.sass" + ], + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "StyleSheet Variable String", + "scope": [ + "variable.css.string", + "variable.scss.string", + "variable.less.string", + "variable.sass.string" + ], + "settings": { + "foreground": "#f9e2af" + } + }, + { + "name": "StyleSheet Unit", + "scope": [ + "unit.css", + "unit.scss", + "unit.less", + "unit.sass" + ], + "settings": { + "foreground": "#cba6f7" + } + }, + { + "name": "StyleSheet Function", + "scope": [ + "function.css", + "function.scss", + "function.less", + "function.sass" + ], + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "Library variable", + "scope": "support.other.variable", + "settings": { + "fontStyle": "" + } + }, + { + "name": "Invalid", + "scope": "invalid", + "settings": { + "background": "#cba6f7", + "foreground": "#f5e0dc" + } + }, + { + "name": "Invalid deprecated", + "scope": "invalid.deprecated", + "settings": { + "background": "#89dceb", + "foreground": "#f5e0dc" + } + }, + { + "name": "JSON String", + "scope": "support.type.property-name.json", + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "Link", + "scope": "string.detected-link", + "settings": { + "foreground": "#89b4fa" + } + }, + { + "name": "diff.header", + "scope": [ + "meta.diff", + "meta.diff.header" + ], + "settings": { + "foreground": "#75715E" + } + }, + { + "name": "diff.deleted", + "scope": "markup.deleted", + "settings": { + "foreground": "#f38ba8" + } + }, + { + "name": "diff.inserted", + "scope": "markup.inserted", + "settings": { + "foreground": "#a6e3a1" + } + }, + { + "name": "diff.changed", + "scope": "markup.changed", + "settings": { + "foreground": "#f9e2af" + } + }, + { + "scope": "constant.numeric.line-number.find-in-files - match", + "settings": { + "foreground": "#56b6c2A0" + } + }, + { + "scope": "entity.name.filename.find-in-files", + "settings": { + "foreground": "#f9e2af" + } + }, + { + "name": "Markup: Emphasis", + "scope": "markup.italic, markup.italic.markdown", + "settings": { + "fontStyle": "italic" + } + }, + { + "name": "Markup: Punctuation", + "scope": [ + "punctuation.definition.italic.markdown", + "punctuation.definition.bold.markdown", + "punctuation.definition.heading.markdown" + ], + "settings": { + "foreground": "#6c7086" + } + }, + { + "name": "Markup: Emphasis Punctuation", + "scope": "punctuation.definition.italic.markdown", + "settings": { + "fontStyle": "italic" + } + }, + { + "name": "Markdown: Link", + "scope": "markup.underline.link.markdown", + "settings": { + "foreground": "#89b4fa" + } + }, + { + "name": "Markup: Bold", + "scope": "markup.bold.markdown", + "settings": { + "fontStyle": "bold" + } + }, + { + "name": "Markup: Heading", + "scope": "markup.heading.markdown", + "settings": { + "fontStyle": "bold", + "foreground": "#f38ba8" + } + }, + { + "name": "Markup: Quote", + "scope": "markup.quote.markdown", + "settings": { + "foreground": "#a6e3a1" + } + }, + { + "name": "Markup: Separator", + "scope": "meta.separator.markdown", + "settings": { + "foreground": "#cba6f7", + "fontStyle": "bold" + } + }, + { + "name": "Markup: Raw", + "scope": [ + "markup.raw.inline.markdown", + "markup.raw.block.markdown" + ], + "settings": { + "foreground": "#89dceb" + } + }, + { + "name": "Markup: List Punctuation", + "scope": "punctuation.definition.list_item.markdown", + "settings": { + "fontStyle": "bold", + "foreground": "#ffffff" + } + } + ], + "colors": { + "focusBorder": "#00000000", + "foreground": "#cdd6f4", + "disabledForeground": "#a6adc8", + "widget.shadow": "#17131F7f", + "selection.background": "#585b70", + "descriptionForeground": "#cdd6f4", + "errorForeground": "#f38ba8", + "icon.foreground": "#cba6f7", + "sash.hoverBorder": "#cba6f7", + "window.activeBorder": "#00000000", + "window.inactiveBorder": "#00000000", + "textBlockQuote.background": "#17131F", + "textBlockQuote.border": "#17131F", + "textCodeBlock.background": "#17131F", + "textLink.activeForeground": "#89dceb", + "textLink.foreground": "#89b4fa", + "textPreformat.foreground": "#cdd6f4", + "textSeparator.foreground": "#cba6f7", + "activityBar.background": "#17131F", + "activityBar.foreground": "#cba6f7", + "activityBar.dropBar": "#585b7099", + "activityBar.inactiveForeground": "#6c7086", + "activityBar.border": "#00000000", + "activityBarBadge.background": "#cba6f7", + "activityBarBadge.foreground": "#17131F", + "activityBar.activeBorder": "#00000000", + "activityBar.activeBackground": "#00000000", + "activityBar.activeFocusBorder": "#00000000", + "badge.background": "#45475a", + "badge.foreground": "#cdd6f4", + "breadcrumb.activeSelectionForeground": "#cba6f7", + "breadcrumb.background": "#17131F", + "breadcrumb.focusForeground": "#cba6f7", + "breadcrumb.foreground": "#cdd6f4cc", + "breadcrumbPicker.background": "#17131F", + "button.background": "#cba6f7", + "button.foreground": "#17131F", + "button.border": "#00000000", + "button.separator": "#00000000", + "button.hoverBackground": "#dfbaff", + "button.secondaryForeground": "#cdd6f4", + "button.secondaryBackground": "#585b70", + "button.secondaryHoverBackground": "#6c6f84", + "checkbox.background": "#45475a", + "checkbox.border": "#00000000", + "checkbox.foreground": "#cba6f7", + "dropdown.background": "#17131F", + "dropdown.listBackground": "#585b70", + "dropdown.border": "#cba6f7", + "dropdown.foreground": "#cdd6f4", + "debugToolBar.background": "#17131F", + "debugToolBar.border": "#00000000", + "debugExceptionWidget.background": "#17131F", + "debugExceptionWidget.border": "#cba6f7", + "debugTokenExpression.number": "#fab387", + "debugTokenExpression.boolean": "#cba6f7", + "debugTokenExpression.string": "#a6e3a1", + "debugTokenExpression.error": "#f38ba8", + "debugIcon.breakpointForeground": "#f38ba8", + "debugIcon.breakpointDisabledForeground": "#f38ba899", + "debugIcon.breakpointUnverifiedForeground": "#17131F", + "debugIcon.breakpointCurrentStackframeForeground": "#585b70", + "debugIcon.breakpointStackframeForeground": "#585b70", + "debugIcon.startForeground": "#a6e3a1", + "debugIcon.pauseForeground": "#89b4fa", + "debugIcon.stopForeground": "#f38ba8", + "debugIcon.disconnectForeground": "#585b70", + "debugIcon.restartForeground": "#94e2d5", + "debugIcon.stepOverForeground": "#cba6f7", + "debugIcon.stepIntoForeground": "#cdd6f4", + "debugIcon.stepOutForeground": "#cdd6f4", + "debugIcon.continueForeground": "#a6e3a1", + "debugIcon.stepBackForeground": "#585b70", + "debugConsole.infoForeground": "#89b4fa", + "debugConsole.warningForeground": "#fab387", + "debugConsole.errorForeground": "#f38ba8", + "debugConsole.sourceForeground": "#f5e0dc", + "debugConsoleInputIcon.foreground": "#cdd6f4", + "diffEditor.border": "#585b70", + "diffEditor.insertedTextBackground": "#a6e3a119", + "diffEditor.removedTextBackground": "#f38ba819", + "diffEditor.insertedLineBackground": "#a6e3a126", + "diffEditor.removedLineBackground": "#f38ba826", + "diffEditor.diagonalFill": "#585b7099", + "diffEditorOverview.insertedForeground": "#a6e3a1cc", + "diffEditorOverview.removedForeground": "#f38ba8cc", + "editor.background": "#17131F", + "editor.findMatchBackground": "#585b70", + "editor.findMatchBorder": "#cba6f766", + "editor.findMatchHighlightBackground": "#fab38759", + "editor.findMatchHighlightBorder": "#00000000", + "editor.findRangeHighlightBackground": "#585b704c", + "editor.findRangeHighlightBorder": "#00000000", + "editor.foldBackground": "#89dceb3f", + "editor.foreground": "#cdd6f4", + "editor.hoverHighlightBackground": "#89dceb3f", + "editor.inactiveSelectionBackground": "#00000000", + "editor.lineHighlightBackground": "#cdd6f411", + "editor.lineHighlightBorder": "#17131F", + "editor.rangeHighlightBackground": "#89dceb3f", + "editor.rangeHighlightBorder": "#00000000", + "editor.selectionBackground": "#585b70", + "editor.selectionHighlightBackground": "#9399b266", + "editor.selectionHighlightBorder": "#89dceb33", + "editor.wordHighlightBackground": "#585b70b2", + "editor.wordHighlightStrongBackground": "#585b707f", + "editorBracketMatch.background": "#9399b219", + "editorBracketMatch.border": "#9399b2", + "editorCodeLens.foreground": "#7f849c", + "editorCursor.background": "#17131F", + "editorCursor.foreground": "#f5e0dc", + "editorError.background": "#00000000", + "editorError.border": "#00000000", + "editorError.foreground": "#f38ba8", + "editorGroup.border": "#585b70", + "editorGroup.dropBackground": "#585b7099", + "editorGroup.emptyBackground": "#17131F", + "editorGroupHeader.tabsBackground": "#17131F", + "editorGutter.addedBackground": "#a6e3a1", + "editorGutter.background": "#17131F", + "editorGutter.commentRangeForeground": "#9399b2", + "editorGutter.deletedBackground": "#f38ba8", + "editorGutter.foldingControlForeground": "#9399b2", + "editorGutter.modifiedBackground": "#89dceb", + "editorHoverWidget.background": "#17131F", + "editorHoverWidget.border": "#585b70", + "editorHoverWidget.foreground": "#cdd6f4", + "editorIndentGuide.activeBackground": "#585b70", + "editorIndentGuide.background": "#45475a", + "editorInfo.background": "#00000000", + "editorInfo.border": "#00000000", + "editorInfo.foreground": "#89b4fa", + "editorInlayHint.foreground": "#585b70", + "editorInlayHint.background": "#17131Fbf", + "editorInlayHint.typeForeground": "#bac2de", + "editorInlayHint.typeBackground": "#17131Fbf", + "editorInlayHint.parameterForeground": "#a6adc8", + "editorInlayHint.parameterBackground": "#17131Fbf", + "editorLineNumber.activeForeground": "#cba6f7", + "editorLineNumber.foreground": "#7f849c", + "editorLink.activeForeground": "#cba6f7", + "editorMarkerNavigation.background": "#17131F", + "editorMarkerNavigationError.background": "#f38ba8", + "editorMarkerNavigationInfo.background": "#89b4fa", + "editorMarkerNavigationWarning.background": "#f9e2af", + "editorOverviewRuler.background": "#17131F", + "editorOverviewRuler.border": "#cdd6f411", + "editorRuler.foreground": "#585b70", + "editor.stackFrameHighlightBackground": "#f9e2af26", + "editor.focusedStackFrameHighlightBackground": "#a6e3a126", + "editorStickyScrollHover.background": "#313244", + "editorSuggestWidget.background": "#17131F", + "editorSuggestWidget.border": "#585b70", + "editorSuggestWidget.foreground": "#cdd6f4", + "editorSuggestWidget.highlightForeground": "#cba6f7", + "editorSuggestWidget.selectedBackground": "#313244", + "editorWarning.background": "#00000000", + "editorWarning.border": "#00000000", + "editorWarning.foreground": "#fab387", + "editorWhitespace.foreground": "#9399b266", + "editorWidget.background": "#17131F", + "editorWidget.foreground": "#cdd6f4", + "editorWidget.resizeBorder": "#585b70", + "editorLightBulb.foreground": "#f9e2af", + "extensionButton.prominentForeground": "#17131F", + "extensionButton.prominentBackground": "#cba6f7", + "extensionButton.prominentHoverBackground": "#dfbaff", + "extensionBadge.remoteBackground": "#89b4fa", + "extensionBadge.remoteForeground": "#17131F", + "extensionIcon.starForeground": "#f9e2af", + "extensionIcon.verifiedForeground": "#a6e3a1", + "extensionIcon.preReleaseForeground": "#f5e0dc", + "extensionIcon.sponsorForeground": "#f5c2e7", + "gitDecoration.addedResourceForeground": "#a6e3a1", + "gitDecoration.conflictingResourceForeground": "#cba6f7", + "gitDecoration.deletedResourceForeground": "#f38ba8", + "gitDecoration.ignoredResourceForeground": "#6c7086", + "gitDecoration.modifiedResourceForeground": "#f9e2af", + "gitDecoration.stageDeletedResourceForeground": "#f38ba8", + "gitDecoration.stageModifiedResourceForeground": "#f9e2af", + "gitDecoration.submoduleResourceForeground": "#89b4fa", + "gitDecoration.untrackedResourceForeground": "#a6e3a1", + "input.background": "#313244", + "input.border": "#00000000", + "input.foreground": "#cdd6f4", + "input.placeholderForeground": "#cdd6f472", + "inputOption.activeBackground": "#89b4fa26", + "inputOption.activeBorder": "#17131F33", + "inputOption.activeForeground": "#cdd6f4", + "inputValidation.errorBackground": "#f38ba8", + "inputValidation.errorBorder": "#17131F33", + "inputValidation.errorForeground": "#17131F", + "inputValidation.infoBackground": "#89b4fa", + "inputValidation.infoBorder": "#17131F33", + "inputValidation.infoForeground": "#17131F", + "inputValidation.warningBackground": "#fab387", + "inputValidation.warningBorder": "#17131F33", + "inputValidation.warningForeground": "#17131F", + "list.activeSelectionBackground": "#45475a", + "list.activeSelectionForeground": "#cdd6f4", + "list.dropBackground": "#585b7099", + "list.focusBackground": "#313244", + "list.focusForeground": "#cdd6f4", + "list.focusOutline": "#00000000", + "list.highlightForeground": "#cba6f7", + "list.hoverBackground": "#17131F", + "list.hoverForeground": "#cdd6f4", + "list.inactiveSelectionBackground": "#45475a", + "list.inactiveSelectionForeground": "#cdd6f4", + "list.warningForeground": "#f9e2af", + "listFilterWidget.background": "#45475a", + "listFilterWidget.noMatchesOutline": "#f38ba8", + "listFilterWidget.outline": "#00000000", + "tree.indentGuidesStroke": "#6c7086", + "menu.background": "#17131F", + "menu.border": "#17131F7f", + "menu.foreground": "#cdd6f4", + "menu.selectionBackground": "#585b70", + "menu.selectionBorder": "#00000000", + "menu.selectionForeground": "#cdd6f4", + "menu.separatorBackground": "#585b70", + "menubar.selectionBackground": "#45475a", + "menubar.selectionForeground": "#cdd6f4", + "merge.commonContentBackground": "#45475a", + "merge.commonHeaderBackground": "#585b70", + "merge.currentContentBackground": "#a6e3a133", + "merge.currentHeaderBackground": "#a6e3a166", + "merge.incomingContentBackground": "#89b4fa33", + "merge.incomingHeaderBackground": "#89b4fa66", + "minimap.background": "#17131F7f", + "minimap.errorHighlight": "#f38ba8", + "minimap.findMatchHighlight": "#585b70", + "minimap.selectionHighlight": "#585b70", + "minimap.warningHighlight": "#f9e2af", + "minimapGutter.addedBackground": "#a6e3a1", + "minimapGutter.deletedBackground": "#f38ba8", + "minimapGutter.modifiedBackground": "#89dceb", + "notificationCenter.border": "#cba6f7", + "notificationCenterHeader.foreground": "#cdd6f4", + "notificationCenterHeader.background": "#17131F", + "notificationToast.border": "#cba6f7", + "notifications.foreground": "#cdd6f4", + "notifications.background": "#17131F", + "notifications.border": "#cba6f7", + "notificationLink.foreground": "#89b4fa", + "notificationsErrorIcon.foreground": "#f38ba8", + "notificationsWarningIcon.foreground": "#fab387", + "notificationsInfoIcon.foreground": "#89b4fa", + "panel.background": "#17131F", + "panel.border": "#585b70", + "panelSection.border": "#585b70", + "panelSection.dropBackground": "#585b7099", + "panelTitle.activeBorder": "#cdd6f4", + "panelTitle.activeForeground": "#cdd6f4", + "panelTitle.inactiveForeground": "#a6adc8", + "peekView.border": "#cba6f7", + "peekViewEditor.background": "#17131F", + "peekViewEditor.matchHighlightBackground": "#fab3873f", + "peekViewEditor.matchHighlightBorder": "#fab387", + "peekViewEditorGutter.background": "#17131F", + "peekViewResult.background": "#17131F", + "peekViewResult.fileForeground": "#cdd6f4", + "peekViewResult.lineForeground": "#cdd6f4", + "peekViewResult.matchHighlightBackground": "#fab3873f", + "peekViewResult.selectionBackground": "#313244", + "peekViewResult.selectionForeground": "#cdd6f4", + "peekViewTitle.background": "#17131F", + "peekViewTitleDescription.foreground": "#bac2deb2", + "peekViewTitleLabel.foreground": "#cdd6f4", + "pickerGroup.border": "#cba6f7", + "pickerGroup.foreground": "#cba6f7", + "progressBar.background": "#cba6f7", + "scrollbar.shadow": "#17131F", + "scrollbarSlider.activeBackground": "#31324466", + "scrollbarSlider.background": "#585b707f", + "scrollbarSlider.hoverBackground": "#6c7086", + "settings.focusedRowBackground": "#585b7033", + "settings.headerForeground": "#cdd6f4", + "settings.modifiedItemIndicator": "#cba6f7", + "settings.dropdownBackground": "#45475a", + "settings.dropdownListBorder": "#00000000", + "settings.textInputBackground": "#45475a", + "settings.textInputBorder": "#00000000", + "settings.numberInputBackground": "#45475a", + "settings.numberInputBorder": "#00000000", + "sideBar.background": "#17131F", + "sideBar.dropBackground": "#585b7099", + "sideBar.foreground": "#cdd6f4", + "sideBar.border": "#00000000", + "sideBarSectionHeader.background": "#17131F", + "sideBarSectionHeader.foreground": "#cdd6f4", + "sideBarTitle.foreground": "#cba6f7", + "sideBarTitle.background": "#17131F", + "statusBar.background": "#17131F", + "statusBar.foreground": "#cdd6f4", + "statusBar.border": "#00000000", + "statusBar.noFolderBackground": "#17131F", + "statusBar.noFolderForeground": "#cdd6f4", + "statusBar.debuggingBackground": "#fab387", + "statusBar.debuggingForeground": "#17131F", + "statusBarItem.remoteBackground": "#89b4fa", + "statusBarItem.remoteForeground": "#17131F", + "statusBarItem.activeBackground": "#585b7066", + "statusBarItem.hoverBackground": "#585b7033", + "statusBarItem.prominentForeground": "#cba6f7", + "statusBarItem.prominentBackground": "#00000000", + "statusBarItem.prominentHoverBackground": "#585b7033", + "statusBarItem.errorForeground": "#f38ba8", + "statusBarItem.errorBackground": "#00000000", + "statusBarItem.warningForeground": "#fab387", + "statusBarItem.warningBackground": "#00000000", + "commandCenter.foreground": "#bac2de", + "commandCenter.activeForeground": "#cba6f7", + "commandCenter.background": "#17131F", + "commandCenter.activeBackground": "#585b7033", + "commandCenter.border": "#cba6f7", + "tab.activeBackground": "#17131F", + "tab.activeBorder": "#cba6f7", + "tab.activeBorderTop": "#00000000", + "tab.activeForeground": "#cba6f7", + "tab.border": "#17131F", + "tab.inactiveBackground": "#17131F", + "tab.inactiveForeground": "#6c7086", + "terminal.ansiBlack": "#6c7086", + "terminal.ansiBlue": "#89b4fa", + "terminal.ansiBrightBlack": "#7f849c", + "terminal.ansiBrightBlue": "#89b4fa", + "terminal.ansiBrightCyan": "#89dceb", + "terminal.ansiBrightGreen": "#a6e3a1", + "terminal.ansiBrightMagenta": "#f5c2e7", + "terminal.ansiBrightRed": "#f38ba8", + "terminal.ansiBrightWhite": "#cdd6f4", + "terminal.ansiBrightYellow": "#f9e2af", + "terminal.ansiCyan": "#89dceb", + "terminal.ansiGreen": "#a6e3a1", + "terminal.ansiMagenta": "#f5c2e7", + "terminal.ansiRed": "#f38ba8", + "terminal.ansiWhite": "#9399b2", + "terminal.ansiYellow": "#f9e2af", + "terminal.border": "#585b70", + "terminal.foreground": "#cdd6f4", + "terminal.dropBackground": "#585b7099", + "terminal.selectionBackground": "#585b70", + "terminalCursor.background": "#17131F", + "terminalCursor.foreground": "#f5e0dc", + "titleBar.activeBackground": "#17131F", + "titleBar.activeForeground": "#cdd6f4", + "titleBar.inactiveBackground": "#17131F", + "titleBar.inactiveForeground": "#cdd6f47f", + "titleBar.border": "#00000000", + "welcomePage.tileBackground": "#17131F", + "welcomePage.progress.background": "#17131F", + "welcomePage.progress.foreground": "#cba6f7", + "walkThrough.embeddedEditorBackground": "#17131F4c", + "symbolIcon.textForeground": "#cdd6f4", + "symbolIcon.arrayForeground": "#fab387", + "symbolIcon.booleanForeground": "#cba6f7", + "symbolIcon.classForeground": "#f9e2af", + "symbolIcon.colorForeground": "#f5c2e7", + "symbolIcon.constantForeground": "#fab387", + "symbolIcon.constructorForeground": "#b4befe", + "symbolIcon.enumeratorForeground": "#f9e2af", + "symbolIcon.enumeratorMemberForeground": "#f9e2af", + "symbolIcon.eventForeground": "#f5c2e7", + "symbolIcon.fieldForeground": "#cdd6f4", + "symbolIcon.fileForeground": "#cba6f7", + "symbolIcon.folderForeground": "#cba6f7", + "symbolIcon.functionForeground": "#89b4fa", + "symbolIcon.interfaceForeground": "#f9e2af", + "symbolIcon.keyForeground": "#94e2d5", + "symbolIcon.keywordForeground": "#cba6f7", + "symbolIcon.methodForeground": "#89b4fa", + "symbolIcon.moduleForeground": "#cdd6f4", + "symbolIcon.namespaceForeground": "#f9e2af", + "symbolIcon.nullForeground": "#eba0ac", + "symbolIcon.numberForeground": "#fab387", + "symbolIcon.objectForeground": "#f9e2af", + "symbolIcon.operatorForeground": "#94e2d5", + "symbolIcon.packageForeground": "#f2cdcd", + "symbolIcon.propertyForeground": "#eba0ac", + "symbolIcon.referenceForeground": "#f9e2af", + "symbolIcon.snippetForeground": "#f2cdcd", + "symbolIcon.stringForeground": "#a6e3a1", + "symbolIcon.structForeground": "#94e2d5", + "symbolIcon.typeParameterForeground": "#eba0ac", + "symbolIcon.unitForeground": "#cdd6f4", + "symbolIcon.variableForeground": "#cdd6f4", + "charts.foreground": "#cdd6f4", + "charts.lines": "#bac2de", + "charts.red": "#f38ba8", + "charts.blue": "#89b4fa", + "charts.yellow": "#f9e2af", + "charts.orange": "#fab387", + "charts.green": "#a6e3a1", + "charts.purple": "#cba6f7", + "errorLens.errorBackground": "#f38ba826", + "errorLens.errorMessageBackground": "#f38ba826", + "errorLens.errorBackgroundLight": "#f38ba826", + "errorLens.errorForeground": "#f38ba8", + "errorLens.errorForegroundLight": "#f38ba8", + "errorLens.warningBackground": "#fab38726", + "errorLens.warningMessageBackground": "#fab38726", + "errorLens.warningBackgroundLight": "#fab38726", + "errorLens.warningForeground": "#fab387", + "errorLens.warningForegroundLight": "#fab387", + "errorLens.infoBackground": "#89b4fa26", + "errorLens.infoMessageBackground": "#89b4fa26", + "errorLens.infoBackgroundLight": "#89b4fa26", + "errorLens.infoForeground": "#89b4fa", + "errorLens.infoForegroundLight": "#89b4fa", + "errorLens.hintBackground": "#a6e3a126", + "errorLens.hintMessageBackground": "#a6e3a126", + "errorLens.hintBackgroundLight": "#a6e3a126", + "errorLens.hintForeground": "#a6e3a1", + "errorLens.hintForegroundLight": "#a6e3a1", + "errorLens.statusBarIconErrorForeground": "#f38ba8", + "errorLens.statusBarIconWarningForeground": "#fab387", + "errorLens.statusBarErrorForeground": "#f38ba8", + "errorLens.statusBarWarningForeground": "#fab387", + "errorLens.statusBarInfoForeground": "#89b4fa", + "errorLens.statusBarHintForeground": "#a6e3a1", + "editorBracketHighlight.foreground1": "#f38ba8", + "editorBracketHighlight.foreground2": "#fab387", + "editorBracketHighlight.foreground3": "#f9e2af", + "editorBracketHighlight.foreground4": "#a6e3a1", + "editorBracketHighlight.foreground5": "#74c7ec", + "editorBracketHighlight.foreground6": "#cba6f7", + "editorBracketHighlight.unexpectedBracket.foreground": "#eba0ac" + }, + "aine": { + "bg": "#15131e", + "sidebar": "#15131e", + "hover": "#2a2440", + "active": "#45475a", + "text": "#cdd6f4", + "muted": "#7f849c", + "accent": "#cba6f7", + "border": "#313244", + "add": "#a6e3a1", + "delete": "#f38ba8", + "modified": "#f9e2af", + "addBg": "rgba(166, 227, 161, 0.07)", + "deleteBg": "rgba(243, 139, 168, 0.07)", + "addBorder": "rgba(166, 227, 161, 0.25)", + "deleteBorder": "rgba(243, 139, 168, 0.25)" + } +} \ No newline at end of file diff --git a/aine/themes/github-dark.json b/aine/themes/github-dark.json new file mode 100644 index 0000000..1dc8f4a --- /dev/null +++ b/aine/themes/github-dark.json @@ -0,0 +1,21 @@ +{ + "name": "github-dark", + "type": "dark", + "aine": { + "bg": "#0d1117", + "sidebar": "#010409", + "hover": "#161b22", + "active": "#1f2937", + "text": "#e6edf3", + "muted": "#7d8590", + "accent": "#58a6ff", + "border": "#30363d", + "add": "#3fb950", + "delete": "#f85149", + "modified": "#d29922", + "addBg": "rgba(63, 185, 80, 0.10)", + "deleteBg": "rgba(248, 81, 73, 0.10)", + "addBorder": "rgba(63, 185, 80, 0.30)", + "deleteBorder": "rgba(248, 81, 73, 0.30)" + } +} diff --git a/aine/thread-store.ts b/aine/thread-store.ts new file mode 100644 index 0000000..db7164d --- /dev/null +++ b/aine/thread-store.ts @@ -0,0 +1,154 @@ +/** + * In-memory thread/comment store for one Aine session. + * + * Manages CRUD for review threads and their comments. + * Emits events so the DiffSession can notify SSE subscribers + * and invalidate cached HTML when annotations change. + */ + +import type { + Thread, + ThreadComment, + ThreadStatus, + ThreadAuthor, + Severity, + SessionEvent, +} from "./types.js"; + +// ── Event callback ──────────────────────────────────────────── + +export type ThreadChangeCallback = (event: SessionEvent) => void; + +// ── ThreadStore ─────────────────────────────────────────────── + +export class ThreadStore { + private threads = new Map(); + private onChange: ThreadChangeCallback; + + constructor(onChange: ThreadChangeCallback) { + this.onChange = onChange; + } + + // ── Helpers ───────────────────────────────────────────────── + + private shortId(): string { + return crypto.randomUUID().replace(/-/g, "").slice(0, 12); + } + + /** Find thread by full ID or short prefix (≥4 chars). */ + find(idOrPrefix: string): Thread | null { + const direct = this.threads.get(idOrPrefix); + if (direct) return direct; + if (idOrPrefix.length >= 4) { + for (const t of this.threads.values()) { + if (t.id.startsWith(idOrPrefix)) return t; + } + } + return null; + } + + // ── CRUD ──────────────────────────────────────────────────── + + create(data: { + file: string; + startLine: number; + endLine?: number; + side?: "old" | "new"; + body: string; + severity?: Severity; + author?: ThreadAuthor; + anchorContent?: string; + }): Thread { + const threadId = this.shortId(); + const commentId = this.shortId(); + const now = new Date().toISOString(); + const author = data.author || { name: "user", type: "user" as const }; + + const thread: Thread = { + id: threadId, + file: data.file, + startLine: data.startLine, + endLine: data.endLine ?? data.startLine, + side: data.side || "new", + status: "open", + anchorContent: data.anchorContent ?? null, + createdAt: now, + updatedAt: now, + comments: [{ + id: commentId, + threadId, + author, + body: data.body, + severity: data.severity, + createdAt: now, + }], + }; + this.threads.set(threadId, thread); + this.onChange({ type: "thread-created", thread }); + return thread; + } + + addReply(threadId: string, body: string, author?: ThreadAuthor): ThreadComment | null { + const thread = this.find(threadId); + if (!thread) return null; + const comment: ThreadComment = { + id: this.shortId(), + threadId: thread.id, + author: author || { name: "user", type: "user" as const }, + body, + createdAt: new Date().toISOString(), + }; + thread.comments.push(comment); + thread.updatedAt = comment.createdAt; + this.onChange({ type: "thread-updated", thread }); + return comment; + } + + updateStatus(id: string, status: ThreadStatus): Thread | null { + const thread = this.find(id); + if (!thread) return null; + thread.status = status; + thread.updatedAt = new Date().toISOString(); + this.onChange({ type: "thread-updated", thread }); + return thread; + } + + edit(id: string, body: string): Thread | null { + const thread = this.find(id); + if (!thread || thread.comments.length === 0) return null; + thread.comments[0].body = body; + thread.updatedAt = new Date().toISOString(); + this.onChange({ type: "thread-updated", thread }); + return thread; + } + + delete(id: string): boolean { + const thread = this.find(id); + if (!thread) return false; + this.threads.delete(thread.id); + this.onChange({ type: "thread-deleted", id: thread.id }); + return true; + } + + // ── Queries ───────────────────────────────────────────────── + + get(id: string): Thread | null { + return this.find(id); + } + + list(filter?: { file?: string; status?: ThreadStatus }): Thread[] { + let all = [...this.threads.values()]; + if (filter?.file) all = all.filter(t => t.file === filter.file); + if (filter?.status) all = all.filter(t => t.status === filter.status); + return all; + } + + get count(): number { + return this.threads.size; + } + + /** Get threads for a specific file (used for diff annotations). */ + forFile(filePath: string): Thread[] { + return [...this.threads.values()].filter(t => t.file === filePath); + } +} diff --git a/aine/types.ts b/aine/types.ts new file mode 100644 index 0000000..d4a831f --- /dev/null +++ b/aine/types.ts @@ -0,0 +1,95 @@ +/** + * Shared types used by both backend (server, CLI) and frontend (Svelte UI). + * Single source of truth — no duplicates. + */ + +// ── File / Diff types ───────────────────────────────────────── + +export type FileStatus = "added" | "deleted" | "modified" | "renamed" | "copied"; + +export interface ChangedFile { + path: string; + oldPath?: string; + status: FileStatus; + untracked?: boolean; + additions: number; + deletions: number; +} + +export interface RepoInfo { + name: string; + branch: string; + description: string; + fileCount: number; + cached: number; + total: number; +} + +export interface RenderOptions { + diffStyle: "split" | "unified"; + expandUnchanged: boolean; + ignoreWhitespace: boolean; +} + +// ── Thread / Comment types ──────────────────────────────────── + +export type Severity = "must-fix" | "suggestion" | "nit"; +export type ThreadStatus = "open" | "resolved" | "dismissed"; + +export interface ThreadAuthor { + name: string; + type: "user" | "agent"; +} + +export interface ThreadComment { + id: string; + threadId: string; + author: ThreadAuthor; + body: string; + severity?: Severity; + createdAt: string; +} + +export interface Thread { + id: string; + file: string; + startLine: number; + endLine: number; + side: "old" | "new"; + status: ThreadStatus; + anchorContent: string | null; + createdAt: string; + updatedAt: string; + comments: ThreadComment[]; +} + +// ── Theme types ─────────────────────────────────────────────── + +export interface ThemeColors { + bg: string; + sidebar: string; + hover: string; + active: string; + text: string; + muted: string; + accent: string; + border: string; + add: string; + delete: string; + modified: string; + addBg: string; + deleteBg: string; + addBorder: string; + deleteBorder: string; +} + +// ── SSE event types ─────────────────────────────────────────── + +export type SessionEvent = + | { type: "thread-created"; thread: Thread } + | { type: "thread-updated"; thread: Thread } + | { type: "thread-deleted"; id: string } + | { type: "navigate"; file: string; hunk?: number; line?: number } + | { type: "reload"; diffArgs: string[]; description: string } + | { type: "refresh" } + | { type: "stale" }; diff --git a/aine/vite.config.ts b/aine/vite.config.ts new file mode 100644 index 0000000..18d243c --- /dev/null +++ b/aine/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig(({ command }) => ({ + plugins: [svelte(), ...(command === "build" ? [viteSingleFile()] : [])], + root: "src", + build: { + outDir: "../dist", + emptyOutDir: true, + }, + server: { + port: 5492, + proxy: { + "/api": "http://localhost:5491", + }, + }, +})); diff --git a/extensions/aine/index.ts b/extensions/aine/index.ts new file mode 100644 index 0000000..77bccb7 --- /dev/null +++ b/extensions/aine/index.ts @@ -0,0 +1,233 @@ +/** + * Aine – Pi extension (thin wrapper around standalone aine CLI). + * + * Commands: + * /aine → open diff viewer + * /aine main → compare current branch against main + * /aine-review → AI reviews current diff and leaves comments + * /aine-resolve → resolve open comments by making code changes + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { spawn, execFileSync, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const STANDALONE_CLI = join(__dirname, "..", "..", "aine", "cli.ts"); +const PORT = 5491; + +let aineProcess: ChildProcess | null = null; + +function stopAine() { + if (aineProcess) { + try { aineProcess.kill(); } catch {} + aineProcess = null; + } +} + +function aineCli(args: string[]): string[] { + if (existsSync(STANDALONE_CLI)) { + return ["bun", STANDALONE_CLI, ...args]; + } + return ["aine", ...args]; +} + +function aineExec(args: string[], cwd: string): string { + const [cmd, ...cmdArgs] = aineCli(args); + return execFileSync(cmd, cmdArgs, { + cwd, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + timeout: 10000, + }).trim(); +} + +function startAine(args: string[], cwd: string): Promise { + stopAine(); + + return new Promise((resolve, reject) => { + const [cmd, ...cmdArgs] = aineCli(args); + const proc = spawn(cmd, cmdArgs, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + + aineProcess = proc; + let started = false; + + proc.stdout?.on("data", (data: Buffer) => { + const text = data.toString(); + if (!started && text.includes("aine →")) { + started = true; + resolve(); + } + }); + + proc.stderr?.on("data", (data: Buffer) => { + if (!started) reject(new Error(data.toString().trim())); + }); + + proc.on("error", (err) => { + if (!started) reject(err); + }); + + proc.on("exit", () => { aineProcess = null; }); + + setTimeout(() => { + if (!started) reject(new Error("Timeout starting aine")); + }, 10000); + }); +} + +/** Ensure an aine session is running, start one if needed. */ +async function ensureSession(cwd: string): Promise { + try { + const out = aineExec(["session", "list", "--json"], cwd); + const sessions = JSON.parse(out); + if (sessions.length > 0) return true; + } catch {} + + // No session running — start one + try { + await startAine(["--port", String(PORT), "--no-open"], cwd); + return true; + } catch { + return false; + } +} + +export default function (pi: ExtensionAPI) { + // ── /aine – open diff viewer ────────────────────────────────── + + pi.registerCommand("aine", { + description: "Open diff viewer (/aine [ref] [ref2])", + handler: async (args, ctx) => { + const cliArgs: string[] = ["--port", String(PORT)]; + + if (args?.trim()) { + const refs = args.trim().split(/\s+/).filter(Boolean); + cliArgs.push(...refs); + } + + try { + await startAine(cliArgs, ctx.cwd); + ctx.ui.notify(`aine → http://localhost:${PORT}`, "info"); + } catch (err) { + ctx.ui.notify("Failed to start aine: " + err, "error"); + } + }, + }); + + // ── /aine-review – AI reviews diff and leaves comments ──────── + + pi.registerCommand("aine-review", { + description: "AI reviews current diff and leaves inline comments", + handler: async (args, ctx) => { + if (!await ensureSession(ctx.cwd)) { + ctx.ui.notify("Failed to start aine session", "error"); + return; + } + + // Get the context + let context: any; + try { + const out = aineExec(["session", "context", "--repo", ".", "--json"], ctx.cwd); + context = JSON.parse(out); + } catch (e) { + ctx.ui.notify("Failed to get aine session context: " + e, "error"); + return; + } + + // Build review prompt with file list and focus area + const focus = args?.trim() || ""; + const fileList = context.files + .map((f: any) => { + const stat = f.status === "added" ? "+" : f.status === "deleted" ? "-" : "~"; + return ` ${stat} ${f.path} (+${f.additions} -${f.deletions})`; + }) + .join("\n"); + + const reviewPrompt = `Review the following code changes and leave inline comments using \`aine session comment add\`. + +Repository: ${context.repoName} (${context.branch}) +Diff: ${context.description} +Files: +${fileList} +${focus ? `\nFocus: ${focus}` : ""} + +For each issue found, run: + aine session comment add --repo . --file --line --summary "" --severity + +Guidelines: +- Use severity levels: must-fix (bugs, security), suggestion (improvements), nit (style/minor) +- Be specific: reference the actual code, explain why it's a problem and how to fix it +- Don't comment on everything — focus on what matters +- After reviewing, summarize your findings + +Read each changed file to understand the full context before commenting.`; + + // Send as assistant message for the agent to execute + ctx.appendMessage("user", reviewPrompt); + }, + }); + + // ── /aine-resolve – resolve open comments ───────────────────── + + pi.registerCommand("aine-resolve", { + description: "Read open comments and make code changes to resolve them", + handler: async (args, ctx) => { + // Get comments + let commentsJson: string; + try { + commentsJson = aineExec(["session", "comment", "list", "--repo", ".", "--json"], ctx.cwd); + } catch (e) { + ctx.ui.notify("Failed to get comments: " + e, "error"); + return; + } + + let allComments: any[]; + try { + allComments = JSON.parse(commentsJson); + } catch { + ctx.ui.notify("No active aine session", "error"); + return; + } + + // Filter: only unresolved, or specific ID + const targetId = args?.trim(); + const toResolve = targetId + ? allComments.filter(c => c.id === targetId) + : allComments.filter(c => !c.resolved); + + if (toResolve.length === 0) { + ctx.ui.notify("No comments to resolve", "info"); + return; + } + + const commentList = toResolve + .map((c: any) => { + const sev = c.severity ? ` [${c.severity}]` : ""; + return `- ${c.id} ${c.file}:${c.line}${sev} — ${c.summary}`; + }) + .join("\n"); + + const resolvePrompt = `Resolve the following code review comments by making the necessary code changes. + +Comments to resolve: +${commentList} + +For each comment: +1. Read the file to understand the context +2. Make the code change to address the feedback +3. After fixing, mark as resolved: \`aine session comment resolve --repo . \` + +Work through them one by one. If a comment is unclear or you disagree, explain why instead of blindly applying it.`; + + ctx.appendMessage("user", resolvePrompt); + }, + }); + + pi.on("session_shutdown", stopAine); +} diff --git a/package.json b/package.json index 9f16319..4265a43 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "./extensions/cmux/index.ts", "./extensions/session-snap/index.ts", "./extensions/pi-telescope/index.ts", - "./extensions/piff/index.ts" + "./extensions/aine/index.ts" ], "skills": [ "./skills/commit", -- 2.51.2 From 65c90a0857eab41c534593178401cce4e223755d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Tom=C5=A1ej?= Date: Fri, 17 Apr 2026 15:59:11 +0200 Subject: [PATCH 18/66] chore: sync extensions, skills, and deps Adds handoff, pi-sem, tool-pills (rewrite), sem skill, leader-key refactor, permissions events, review sem-guidance. Removes aine, piff, session-switch. Bumps deps (pi-claude-code-use, pi-vcc, pi-tasks, optional sem). Ignores .sem/ cache. --- .gitignore | 1 + README.md | 23 +- aine/bun.lock | 426 --- aine/cli.ts | 279 -- aine/config.ts | 82 - aine/diff-session.ts | 403 --- aine/dist/index.html | 77 - aine/git.ts | 363 --- aine/lru-cache.ts | 57 - aine/notes/diffity-analysis.md | 127 - aine/notes/plan.md | 88 - aine/notes/review-workflow-brainstorm.md | 503 ---- aine/package.json | 32 - aine/routes.ts | 340 --- aine/server.ts | 152 -- aine/session-cli.ts | 299 --- aine/session-store.ts | 105 - aine/skills/aine-review/SKILL.md | 141 - aine/src/App.svelte | 251 -- aine/src/components/CommentInput.svelte | 83 - aine/src/components/CommentsPanel.svelte | 444 ---- aine/src/components/DiffView.svelte | 682 ----- aine/src/components/HelpOverlay.svelte | 129 - aine/src/components/Sidebar.svelte | 204 -- aine/src/components/Toast.svelte | 45 - aine/src/components/Toolbar.svelte | 293 --- aine/src/index.html | 12 - aine/src/lib/api.ts | 106 - aine/src/lib/diff-highlights.ts | 177 -- aine/src/lib/events.ts | 50 - aine/src/lib/file-tree.ts | 134 - aine/src/lib/keyboard.ts | 97 - aine/src/lib/state.ts | 53 - aine/src/lib/theme-apply.ts | 21 - aine/src/main.ts | 6 - aine/ssr-worker-client.ts | 190 -- aine/ssr-worker.ts | 138 - aine/svelte.config.js | 5 - aine/tests/api/server.test.ts | 132 - aine/tests/api/session.test.ts | 125 - aine/tests/api/threads.test.ts | 164 -- aine/tests/coverage/base.css | 224 -- aine/tests/coverage/block-navigation.js | 87 - aine/tests/coverage/clover.xml | 78 - aine/tests/coverage/coverage-final.json | 3 - aine/tests/coverage/favicon.png | Bin 445 -> 0 bytes aine/tests/coverage/index.html | 131 - aine/tests/coverage/prettify.css | 1 - aine/tests/coverage/prettify.js | 2 - aine/tests/coverage/setup-repo.ts.html | 400 --- aine/tests/coverage/sort-arrow-sprite.png | Bin 138 -> 0 bytes aine/tests/coverage/sorter.js | 210 -- aine/tests/coverage/test-server.ts.html | 409 --- aine/tests/e2e/diff-view.test.ts | 115 - aine/tests/e2e/helpers.ts | 54 - aine/tests/e2e/navigation.test.ts | 123 - aine/tests/e2e/threads.test.ts | 134 - aine/tests/fixtures/setup-repo.ts | 105 - aine/tests/fixtures/test-server.ts | 108 - aine/tests/fixtures/vitest-setup.ts | 16 - aine/tests/playwright.config.ts | 18 - aine/tests/unit/git.test.ts | 179 -- aine/tests/unit/lru-cache.test.ts | 97 - aine/tests/vitest.config.ts | 12 - aine/theme.ts | 149 -- aine/themes/catppuccin-mocha.json | 973 ------- aine/themes/github-dark.json | 21 - aine/thread-store.ts | 154 -- aine/types.ts | 95 - aine/vite.config.ts | 18 - extensions/aine/index.ts | 233 -- extensions/cmux/cmux-client.ts | 76 +- extensions/cmux/tools.ts | 552 +++- extensions/custom-footer/renderers.ts | 18 +- extensions/handoff/index.ts | 732 ++++++ extensions/leader-key/context-helpers.ts | 111 + extensions/leader-key/favourite-models.json | 36 +- extensions/leader-key/favourite-models.ts | 138 +- extensions/leader-key/index.ts | 206 +- extensions/leader-key/label-actions.ts | 141 + extensions/leader-key/session-actions.ts | 166 ++ extensions/leader-key/types.ts | 23 + extensions/permissions/events.ts | 13 + extensions/permissions/permissions.ts | 99 +- extensions/permissions/settings.ts | 3 +- extensions/permissions/types.ts | 7 +- extensions/pi-sem/README.md | 76 + extensions/pi-sem/bin/sem-eval.mjs | 182 ++ extensions/pi-sem/core.mjs | 458 ++++ extensions/pi-sem/core.test.mjs | 101 + extensions/pi-sem/index.ts | 499 ++++ extensions/pi-sem/package.json | 12 + extensions/pi-telescope/index.ts | 173 +- extensions/pi-telescope/preview.ts | 9 +- extensions/pi-telescope/providers/files.ts | 11 +- extensions/pi-telescope/telescope.ts | 73 +- extensions/piff/alt-catppuccin-mocha.json | 956 ------- extensions/piff/cli.ts | 44 - extensions/piff/diff-session.ts | 311 --- extensions/piff/git.ts | 384 --- extensions/piff/index.ts | 54 - extensions/piff/lru-cache.ts | 41 - extensions/piff/package-lock.json | 2338 ----------------- extensions/piff/package.json | 17 - extensions/piff/server.ts | 197 -- extensions/piff/src/App.svelte | 281 -- .../piff/src/components/DiffView.svelte | 388 --- .../piff/src/components/HelpOverlay.svelte | 120 - extensions/piff/src/components/Sidebar.svelte | 385 --- extensions/piff/src/components/Toast.svelte | 45 - extensions/piff/src/components/Toolbar.svelte | 243 -- extensions/piff/src/index.html | 12 - extensions/piff/src/lib/api.ts | 47 - extensions/piff/src/lib/keyboard.ts | 93 - extensions/piff/src/lib/state.ts | 49 - extensions/piff/src/main.ts | 6 - extensions/piff/ssr-worker-client.ts | 177 -- extensions/piff/ssr-worker.ts | 112 - extensions/piff/svelte.config.js | 5 - extensions/piff/vite.config.ts | 18 - extensions/review/review.ts | 27 + extensions/review/sem-guidance.mjs | 83 + extensions/review/sem-guidance.test.mjs | 39 + extensions/session-query/session-query.ts | 129 +- extensions/session-switch/index.ts | 452 ---- extensions/shared/thinking-colors.ts | 9 + extensions/tool-pills/diff-renderer.ts | 1543 +++++++++++ extensions/tool-pills/index.ts | 139 + extensions/tool-pills/package-lock.json | 630 +++++ extensions/tool-pills/package.json | 11 + extensions/tool-pills/pill.ts | 24 + extensions/tool-pills/tool-pills.ts | 205 -- package-lock.json | 56 +- package.json | 21 +- skills/sem/SKILL.md | 72 + 135 files changed, 6198 insertions(+), 18668 deletions(-) delete mode 100644 aine/bun.lock delete mode 100644 aine/cli.ts delete mode 100644 aine/config.ts delete mode 100644 aine/diff-session.ts delete mode 100644 aine/dist/index.html delete mode 100644 aine/git.ts delete mode 100644 aine/lru-cache.ts delete mode 100644 aine/notes/diffity-analysis.md delete mode 100644 aine/notes/plan.md delete mode 100644 aine/notes/review-workflow-brainstorm.md delete mode 100644 aine/package.json delete mode 100644 aine/routes.ts delete mode 100644 aine/server.ts delete mode 100644 aine/session-cli.ts delete mode 100644 aine/session-store.ts delete mode 100644 aine/skills/aine-review/SKILL.md delete mode 100644 aine/src/App.svelte delete mode 100644 aine/src/components/CommentInput.svelte delete mode 100644 aine/src/components/CommentsPanel.svelte delete mode 100644 aine/src/components/DiffView.svelte delete mode 100644 aine/src/components/HelpOverlay.svelte delete mode 100644 aine/src/components/Sidebar.svelte delete mode 100644 aine/src/components/Toast.svelte delete mode 100644 aine/src/components/Toolbar.svelte delete mode 100644 aine/src/index.html delete mode 100644 aine/src/lib/api.ts delete mode 100644 aine/src/lib/diff-highlights.ts delete mode 100644 aine/src/lib/events.ts delete mode 100644 aine/src/lib/file-tree.ts delete mode 100644 aine/src/lib/keyboard.ts delete mode 100644 aine/src/lib/state.ts delete mode 100644 aine/src/lib/theme-apply.ts delete mode 100644 aine/src/main.ts delete mode 100644 aine/ssr-worker-client.ts delete mode 100644 aine/ssr-worker.ts delete mode 100644 aine/svelte.config.js delete mode 100644 aine/tests/api/server.test.ts delete mode 100644 aine/tests/api/session.test.ts delete mode 100644 aine/tests/api/threads.test.ts delete mode 100644 aine/tests/coverage/base.css delete mode 100644 aine/tests/coverage/block-navigation.js delete mode 100644 aine/tests/coverage/clover.xml delete mode 100644 aine/tests/coverage/coverage-final.json delete mode 100644 aine/tests/coverage/favicon.png delete mode 100644 aine/tests/coverage/index.html delete mode 100644 aine/tests/coverage/prettify.css delete mode 100644 aine/tests/coverage/prettify.js delete mode 100644 aine/tests/coverage/setup-repo.ts.html delete mode 100644 aine/tests/coverage/sort-arrow-sprite.png delete mode 100644 aine/tests/coverage/sorter.js delete mode 100644 aine/tests/coverage/test-server.ts.html delete mode 100644 aine/tests/e2e/diff-view.test.ts delete mode 100644 aine/tests/e2e/helpers.ts delete mode 100644 aine/tests/e2e/navigation.test.ts delete mode 100644 aine/tests/e2e/threads.test.ts delete mode 100644 aine/tests/fixtures/setup-repo.ts delete mode 100644 aine/tests/fixtures/test-server.ts delete mode 100644 aine/tests/fixtures/vitest-setup.ts delete mode 100644 aine/tests/playwright.config.ts delete mode 100644 aine/tests/unit/git.test.ts delete mode 100644 aine/tests/unit/lru-cache.test.ts delete mode 100644 aine/tests/vitest.config.ts delete mode 100644 aine/theme.ts delete mode 100644 aine/themes/catppuccin-mocha.json delete mode 100644 aine/themes/github-dark.json delete mode 100644 aine/thread-store.ts delete mode 100644 aine/types.ts delete mode 100644 aine/vite.config.ts delete mode 100644 extensions/aine/index.ts create mode 100644 extensions/handoff/index.ts create mode 100644 extensions/leader-key/context-helpers.ts create mode 100644 extensions/leader-key/label-actions.ts create mode 100644 extensions/leader-key/session-actions.ts create mode 100644 extensions/leader-key/types.ts create mode 100644 extensions/permissions/events.ts create mode 100644 extensions/pi-sem/README.md create mode 100755 extensions/pi-sem/bin/sem-eval.mjs create mode 100644 extensions/pi-sem/core.mjs create mode 100644 extensions/pi-sem/core.test.mjs create mode 100644 extensions/pi-sem/index.ts create mode 100644 extensions/pi-sem/package.json delete mode 100644 extensions/piff/alt-catppuccin-mocha.json delete mode 100644 extensions/piff/cli.ts delete mode 100644 extensions/piff/diff-session.ts delete mode 100644 extensions/piff/git.ts delete mode 100644 extensions/piff/index.ts delete mode 100644 extensions/piff/lru-cache.ts delete mode 100644 extensions/piff/package-lock.json delete mode 100644 extensions/piff/package.json delete mode 100644 extensions/piff/server.ts delete mode 100644 extensions/piff/src/App.svelte delete mode 100644 extensions/piff/src/components/DiffView.svelte delete mode 100644 extensions/piff/src/components/HelpOverlay.svelte delete mode 100644 extensions/piff/src/components/Sidebar.svelte delete mode 100644 extensions/piff/src/components/Toast.svelte delete mode 100644 extensions/piff/src/components/Toolbar.svelte delete mode 100644 extensions/piff/src/index.html delete mode 100644 extensions/piff/src/lib/api.ts delete mode 100644 extensions/piff/src/lib/keyboard.ts delete mode 100644 extensions/piff/src/lib/state.ts delete mode 100644 extensions/piff/src/main.ts delete mode 100644 extensions/piff/ssr-worker-client.ts delete mode 100644 extensions/piff/ssr-worker.ts delete mode 100644 extensions/piff/svelte.config.js delete mode 100644 extensions/piff/vite.config.ts create mode 100644 extensions/review/sem-guidance.mjs create mode 100644 extensions/review/sem-guidance.test.mjs delete mode 100644 extensions/session-switch/index.ts create mode 100644 extensions/shared/thinking-colors.ts create mode 100644 extensions/tool-pills/diff-renderer.ts create mode 100644 extensions/tool-pills/index.ts create mode 100644 extensions/tool-pills/package-lock.json create mode 100644 extensions/tool-pills/package.json create mode 100644 extensions/tool-pills/pill.ts delete mode 100644 extensions/tool-pills/tool-pills.ts create mode 100644 skills/sem/SKILL.md diff --git a/.gitignore b/.gitignore index c3bf47c..46202f2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ *.log .pi/ extensions/piff/dist/ +.sem/ diff --git a/README.md b/README.md index d2882c8..fa2eacc 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,15 @@ See the [full README](extensions/pi-web-access/README.md) for details. ### [Code Review](extensions/review/) -`/review` command with multiple modes: review a GitHub PR (checks it out locally), diff against a base branch, review uncommitted changes, review a specific commit, or provide custom review instructions. Supports project-specific `REVIEW_GUIDELINES.md`. Derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff) (Apache 2.0). +`/review` command with multiple modes: review a GitHub PR (checks it out locally), diff against a base branch, review uncommitted changes, review a specific commit, or provide custom review instructions. Supports project-specific `REVIEW_GUIDELINES.md`. + +When `pi-sem` is also loaded, `/review` now nudges the agent toward a semantic workflow: +- `sem_diff` for one overview of changed entities +- `sem_impact` for blast radius / affected tests on risky entities +- `sem_context` for focused understanding of suspicious functions or classes +- raw `git diff` / `read` for final line-level evidence + +Derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff) (Apache 2.0). ### [Todos](extensions/todos/) @@ -86,6 +94,17 @@ File-based todo management stored in `.pi/todos/`. Each todo is a standalone mar `/context` command showing what's loaded in the current session: extensions, skills, project context files (`AGENTS.md`/`CLAUDE.md`), context window usage, and session cost totals. Derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff) (Apache 2.0). +### [pi-sem](extensions/pi-sem/) + +Semantic Git tooling for Pi powered by [sem](https://github.com/Ataraxy-Labs/sem). Exposes entity-aware tools like `sem_diff`, `sem_impact`, `sem_context`, `sem_log`, `sem_entities`, `sem_blame`, and `sem_eval` so the agent can reason about functions, classes, and config properties instead of raw line hunks. + +Includes a local evaluator to compare `sem diff` vs `git diff` on the same selection: + +```bash +npm run sem:evaluate -- --staged +npm run sem:evaluate -- --from origin/main --to HEAD +``` + ### [Ghostty](extensions/ghostty/) [Ghostty](https://ghostty.org) terminal integration. Sets dynamic window titles with project, session, and model info. Animates a braille spinner and pulses Ghostty's native progress bar while the agent is working. Based on [pi-ghostty](https://github.com/HazAT/pi-ghostty) by [HazAT](https://github.com/HazAT). @@ -96,6 +115,7 @@ File-based todo management stored in `.pi/todos/`. Each todo is a standalone mar |-------|-------------| | [commit](skills/commit/) | Conventional Commits-style `git commit` — infers type, scope, and summary from the diff | | [github](skills/github/) | Recipes for the `gh` CLI — PR checks, CI runs, issue queries, JSON output | +| [sem](skills/sem/) | Entity-aware change analysis workflow — prefer `sem_context` and `sem_impact`, use `sem_diff` selectively for summaries and reviews | ## Themes @@ -110,6 +130,7 @@ Most extensions work out of the box. Notable config: - **Leader Key** — edit `extensions/leader-key/favourite-models.json` to set your favourite model presets - **Pi Web Access** — optionally configure API keys in `~/.pi/web-search.json` (see [README](extensions/pi-web-access/README.md#configuration)) - **Todos** — set `PI_TODO_PATH` env var to change the storage directory (defaults to `.pi/todos`) +- **pi-sem** — `npm install` should fetch the optional `@ataraxy-labs/sem` wrapper automatically; otherwise install `sem` globally with Homebrew or Cargo ## License diff --git a/aine/bun.lock b/aine/bun.lock deleted file mode 100644 index 8b6e9a9..0000000 --- a/aine/bun.lock +++ /dev/null @@ -1,426 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "aine", - "dependencies": { - "@pierre/diffs": "latest", - }, - "devDependencies": { - "@playwright/test": "^1.58.2", - "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@vitest/coverage-v8": "^4.1.2", - "svelte": "^5.55.0", - "vite": "^8.0.2", - "vite-plugin-singlefile": "^2.3.2", - "vitest": "^4.1.2", - }, - }, - }, - "packages": { - "@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/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@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=="], - - "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - - "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], - - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], - - "@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=="], - - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], - - "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], - - "@pierre/diffs": ["@pierre/diffs@1.1.4", "", { "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-qkLr0ZNhWQuYUozIhOo6Zxm4JhtwLyRXXarwLNDksgSqMcVf8QTtLVm8/TViwdxzrwNPoIEt/FDJpFWYkZjhhg=="], - - "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], - - "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], - - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="], - - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="], - - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="], - - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="], - - "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], - - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], - - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], - - "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - - "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], - - "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], - - "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - - "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], - - "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="], - - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - - "@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/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.57.2", "", {}, "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="], - - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="], - - "@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="], - - "@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="], - - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="], - - "@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="], - - "@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="], - - "@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="], - - "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], - - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - - "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], - - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg=="], - - "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - - "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - - "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=="], - - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - - "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], - - "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - - "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], - - "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], - - "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], - - "esrap": ["esrap@2.2.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" } }, "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg=="], - - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "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=="], - - "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], - - "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], - - "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], - - "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], - - "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], - - "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - - "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], - - "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], - - "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], - - "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=="], - - "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=="], - - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], - - "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], - - "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], - - "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], - - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], - - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - - "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=="], - - "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=="], - - "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], - - "rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - - "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=="], - - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - - "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], - - "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=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "svelte": ["svelte@5.55.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw=="], - - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - - "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], - - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - - "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - - "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "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=="], - - "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=="], - - "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], - - "vite-plugin-singlefile": ["vite-plugin-singlefile@2.3.2", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.59.0", "vite": "^5.4.11 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-b8SxCi/gG7K298oJDcKOuZeU6gf6wIcCJAaEqUmmZXdjfuONlkyNyWZC3tEbN6QockRCNUd3it9eGTtpHGoYmg=="], - - "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=="], - - "vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="], - - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - - "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - } -} diff --git a/aine/cli.ts b/aine/cli.ts deleted file mode 100644 index 5eb2749..0000000 --- a/aine/cli.ts +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env bun -/** - * Aine – Agent-agnostic diff viewer and code review tool. - * - * Usage: - * aine → working tree changes - * aine diff → working tree changes - * aine diff --staged → staged changes only - * aine diff main → diff against main - * aine diff main..feature → compare two branches - * aine show → last commit - * aine show HEAD~3 → specific commit - * aine session list → list running instances - * aine session context --repo . → current session state - */ - -import { isGitRepo, parseRefs, getRepoRoot } from "./git.js"; -import { startServer, stopServer } from "./server.js"; -import { sessionCommand } from "./session-cli.js"; -import { loadConfig } from "./config.js"; - -const DEFAULT_PORT = 5491; - -interface CliOptions { - command: "diff" | "show" | "session" | "help" | "version"; - refs: string[]; - port: number; - portExplicit: boolean; - noOpen: boolean; - staged: boolean; - watch: boolean; - unified: boolean; - forceNew: boolean; - sessionArgs: string[]; -} - -function printHelp() { - console.log(` -aine - Agent-agnostic diff viewer and code review tool - -USAGE - aine Review working tree changes - aine diff [options] [refs] Review diffs - aine show [ref] Review a commit - aine session Control running instances - aine help Show this help - aine version Show version - -DIFF OPTIONS - --staged Show staged changes only - --watch Auto-reload on file changes - --port Custom port (default: auto-assigned) - --no-open Don't open browser - --unified Start in unified view (default: split) - --new Stop existing instance and start fresh - -DIFF EXAMPLES - aine Working tree changes (staged + unstaged + untracked) - aine diff --staged Staged changes only - aine diff main Compare current branch against main - aine diff main..feature Compare two branches - aine diff HEAD~3 Last 3 commits - aine show Review last commit - aine show HEAD~2 Review specific commit - -SESSION COMMANDS - aine session list [--json] - aine session context --repo - aine session navigate --repo --file [--hunk N | --line N] - aine session reload --repo [-- diff --staged] - aine session comment add --repo --file --line --summary "..." - aine session comment list --repo [--json] - aine session comment resolve --repo - aine session comment rm --repo - aine session comment clear --repo --yes -`.trim()); -} - -function parseArgs(argv: string[]): CliOptions { - const opts: CliOptions = { - command: "diff", - refs: [], - port: DEFAULT_PORT, - portExplicit: false, - noOpen: false, - staged: false, - watch: false, - unified: false, - forceNew: false, - sessionArgs: [], - }; - - const args = argv.slice(2); - if (args.length === 0) return opts; - - let i = 0; - - // Parse command - const first = args[0]; - if (first === "help" || first === "--help" || first === "-h") { - opts.command = "help"; - return opts; - } - if (first === "version" || first === "--version" || first === "-v") { - opts.command = "version"; - return opts; - } - if (first === "session") { - opts.command = "session"; - opts.sessionArgs = args.slice(1); - return opts; - } - if (first === "show") { - opts.command = "show"; - i = 1; - } else if (first === "diff") { - opts.command = "diff"; - i = 1; - } - // else: no subcommand, treat remaining as diff refs - - // Parse remaining args - for (; i < args.length; i++) { - const arg = args[i]; - switch (arg) { - case "--staged": - opts.staged = true; - break; - case "--watch": - opts.watch = true; - break; - case "--no-open": - opts.noOpen = true; - break; - case "--unified": - opts.unified = true; - break; - case "--new": - opts.forceNew = true; - break; - case "--port": - i++; - opts.port = parseInt(args[i], 10); - opts.portExplicit = true; - if (isNaN(opts.port)) { - console.error("Error: --port requires a number"); - process.exit(1); - } - break; - default: - if (arg.startsWith("-")) { - console.error(`Unknown option: ${arg}`); - process.exit(1); - } - opts.refs.push(arg); - break; - } - } - - return opts; -} - -async function main() { - const opts = parseArgs(process.argv); - - if (opts.command === "help") { - printHelp(); - process.exit(0); - } - - if (opts.command === "version") { - const pkg = await import("./package.json"); - console.log(`aine v${pkg.version}`); - process.exit(0); - } - - if (opts.command === "session") { - await sessionCommand(opts.sessionArgs); - process.exit(0); - } - - // diff or show - const cwd = process.cwd(); - - if (!isGitRepo(cwd)) { - console.error("Error: not a git repository"); - process.exit(1); - } - - const repoRoot = getRepoRoot(cwd); - - // Load config (global ← per-repo ← CLI flags) - const config = loadConfig(repoRoot); - - // Apply config defaults where CLI didn't set explicit values - if (!opts.portExplicit) opts.port = config.port; - if (!opts.unified && config.mode === "unified") opts.unified = true; - if (!opts.watch && config.watch) opts.watch = true; - if (opts.noOpen === false && !config.open_browser) opts.noOpen = true; - - // Check for existing session for this repo - const { listSessions, findSessionByRepo } = await import("./session-store.js"); - const existing = findSessionByRepo(repoRoot); - - if (existing && !opts.forceNew) { - // Reuse existing session — just open the browser - console.log(`aine already running → http://localhost:${existing.port}`); - if (!opts.noOpen) { - try { - Bun.spawn(["open", `http://localhost:${existing.port}`], { stdout: "ignore", stderr: "ignore" }); - } catch {} - } - process.exit(0); - } - - if (existing && opts.forceNew) { - // Kill existing session - try { process.kill(existing.pid, "SIGTERM"); } catch {} - await new Promise(r => setTimeout(r, 500)); - } - - // Build diffArgs based on command - let diffArgs: string[]; - let description: string; - - if (opts.command === "show") { - const ref = opts.refs[0] || "HEAD"; - diffArgs = [`${ref}~1..${ref}`]; - description = `Commit ${ref}`; - } else if (opts.staged) { - diffArgs = ["--staged"]; - description = "Staged changes"; - } else { - const refs = opts.refs; - const parsed = parseRefs(refs, repoRoot); - if ("error" in parsed) { - console.error(`Error: invalid git ref '${parsed.error}'`); - process.exit(1); - } - diffArgs = parsed.diffArgs; - description = parsed.description; - } - - // Auto-assign port if not explicitly set - let port = opts.port; - if (!opts.portExplicit) { - const sessions = listSessions(); - const usedPorts = new Set(sessions.map(s => s.port)); - const basePort = config.port; - for (let p = basePort; p < basePort + 100; p++) { - if (!usedPorts.has(p)) { - port = p; - break; - } - } - } - - try { - await startServer({ - port, - diffArgs, - description, - cwd: repoRoot, - unified: opts.unified, - noOpen: opts.noOpen, - watch: opts.watch, - theme: config.theme, - expandUnchanged: config.expand_unchanged, - ignoreWhitespace: config.ignore_whitespace, - }); - console.log(`aine → http://localhost:${port}${opts.watch ? " (watching)" : ""}`); - console.log("Press Ctrl+C to stop"); - } catch (err) { - console.error("Failed to start aine:", err); - process.exit(1); - } -} - -main(); diff --git a/aine/config.ts b/aine/config.ts deleted file mode 100644 index 5f9ffe6..0000000 --- a/aine/config.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Aine configuration loader. - * - * Reads from (in order of priority, later wins): - * 1. ~/.config/aine/config.toml (global) - * 2. .aine/config.toml (per-repo, relative to repo root) - * 3. CLI flags (highest priority, applied by caller) - * - * Simple TOML subset parser — no dependencies needed. - */ - -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; - -export interface AineConfig { - mode: "split" | "unified"; - port: number; - watch: boolean; - open_browser: boolean; - theme: string; - expand_unchanged: boolean; - ignore_whitespace: boolean; -} - -const DEFAULTS: AineConfig = { - mode: "split", - port: 5491, - watch: false, - open_browser: true, - theme: "catppuccin-mocha", - expand_unchanged: true, - ignore_whitespace: false, -}; - -const GLOBAL_CONFIG = join(homedir(), ".config", "aine", "config.toml"); - -/** Minimal TOML parser — handles key = value lines. */ -function parseToml(content: string): Record { - const result: Record = {}; - for (const raw of content.split("\n")) { - const line = raw.trim(); - if (!line || line.startsWith("#") || line.startsWith("[")) continue; - const eq = line.indexOf("="); - if (eq === -1) continue; - const key = line.slice(0, eq).trim(); - let val = line.slice(eq + 1).trim(); - const commentIdx = val.indexOf(" #"); - if (commentIdx > 0) val = val.slice(0, commentIdx).trim(); - if (val === "true") result[key] = true; - else if (val === "false") result[key] = false; - else if (/^-?\d+$/.test(val)) result[key] = parseInt(val, 10); - else if (/^-?\d+\.\d+$/.test(val)) result[key] = parseFloat(val); - else result[key] = val.replace(/^["']|["']$/g, ""); - } - return result; -} - -function loadTomlFile(path: string): Record { - try { return parseToml(readFileSync(path, "utf-8")); } - catch { return {}; } -} - -/** Type-safe merge: only applies values that match the type in DEFAULTS. */ -function applySource(target: AineConfig, source: Record) { - for (const key of Object.keys(DEFAULTS) as (keyof AineConfig)[]) { - if (key in source && typeof source[key] === typeof DEFAULTS[key]) { - (target as any)[key] = source[key]; - } - } -} - -/** - * Load merged config: defaults ← global ← per-repo. - * CLI flags are applied by the caller after this. - */ -export function loadConfig(repoRoot?: string): AineConfig { - const merged = { ...DEFAULTS }; - applySource(merged, loadTomlFile(GLOBAL_CONFIG)); - if (repoRoot) applySource(merged, loadTomlFile(join(repoRoot, ".aine", "config.toml"))); - return merged; -} diff --git a/aine/diff-session.ts b/aine/diff-session.ts deleted file mode 100644 index c643d83..0000000 --- a/aine/diff-session.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { - getChangedFiles, - getOldNewContents, - bulkGetAllContents, - contentHash, - getRepoName, - getCurrentBranch, -} from "./git.js"; -import { - SSRWorker, - stripTrailingWS, - type ThemeInit, - type DiffAnnotation, -} from "./ssr-worker-client.js"; -import { LRUCache } from "./lru-cache.js"; -import { ThreadStore } from "./thread-store.js"; -import type { - ChangedFile, - RenderOptions, - SessionEvent, -} from "./types.js"; - -// ── Constants ───────────────────────────────────────────────── - -const DEFAULT_RENDER_OPTIONS: RenderOptions = { - diffStyle: "split", - expandUnchanged: true, - ignoreWhitespace: false, -}; - -const CACHE_MAX_ENTRIES = 500; -const PRELOAD_WORKERS = 2; - -/** Map file extensions → shiki language IDs for syntax highlighting preload. */ -const EXTENSION_TO_LANG: Record = { - ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", - py: "python", rs: "rust", go: "go", rb: "ruby", - java: "java", kt: "kotlin", swift: "swift", c: "c", cpp: "cpp", - h: "c", hpp: "cpp", cs: "csharp", php: "php", - sh: "bash", zsh: "bash", bash: "bash", - json: "json", yaml: "yaml", yml: "yaml", toml: "toml", - md: "markdown", mdx: "mdx", - css: "css", scss: "scss", html: "html", svelte: "svelte", vue: "vue", - sql: "sql", graphql: "graphql", proto: "protobuf", - dockerfile: "dockerfile", tf: "hcl", -}; - -// ── Helpers ─────────────────────────────────────────────────── - -function optionsKey(opts: RenderOptions): string { - return `${opts.diffStyle}:${opts.expandUnchanged}:${opts.ignoreWhitespace}`; -} - -function applyWhitespace( - oldContent: string | null, - newContent: string | null, - ignoreWhitespace: boolean, -): { oldContent: string | null; newContent: string | null } { - if (!ignoreWhitespace) return { oldContent, newContent }; - return { - oldContent: stripTrailingWS(oldContent), - newContent: stripTrailingWS(newContent), - }; -} - -/** - * Manages diff cache, threads, and background preloading for one Aine session. - * - * Architecture: - * - **userWorker**: dedicated SSR worker for user-initiated renders (never blocked) - * - **preloadWorkers[]**: pool of SSR workers for background preloading - * - **threadStore**: in-memory thread/comment store (delegated) - * - **eventListeners**: SSE subscribers for live UI updates - * - * Preload uses `git cat-file --batch` to bulk-read all file contents (~30× faster - * than sequential `git show`), then distributes SSR rendering across the worker pool. - */ -export class DiffSession { - private cache = new LRUCache(CACHE_MAX_ENTRIES); - private userWorker = new SSRWorker(); - private preloadWorkers: SSRWorker[] = []; - private aborted = false; - private filesCache: ChangedFile[] | null = null; - renderOptions: RenderOptions; - - private knownHashes = new Map(); - private contentsCache = new Map(); - private filesByPath = new Map(); - - // ── Threads (delegated to ThreadStore) ────────────────────── - - private threadStore: ThreadStore; - - // ── Event system (SSE) ──────────────────────────────────────── - - private eventListeners = new Set<(event: SessionEvent) => void>(); - - /** Cached repo metadata. */ - repoName: string; - branch: string; - - /** Current diff args (can be changed via reload). */ - private diffArgs: string[]; - private cwd: string; - private description: string = ""; - - /** Hint: which file the user is currently viewing. */ - currentFile: string | null = null; - - constructor(diffArgs: string[], cwd: string, themeInit?: ThemeInit) { - this.diffArgs = diffArgs; - this.cwd = cwd; - this.renderOptions = { ...DEFAULT_RENDER_OPTIONS }; - this.repoName = getRepoName(cwd); - this.branch = getCurrentBranch(cwd); - - // Thread store emits events through our event system and invalidates cache - this.threadStore = new ThreadStore((event) => { - // Invalidate cached HTML when threads change on a file - if (event.type === "thread-created" || event.type === "thread-updated") { - this.invalidateFile(event.thread.file); - } else if (event.type === "thread-deleted") { - // For delete we need to find the file from the event — but id-only. - // ThreadStore already fired this after removing. We clear all thread-related - // caches on the next render anyway. - } - this.emitEvent(event); - }); - - // Set theme on all workers before starting them - if (themeInit) { - this.userWorker.setTheme(themeInit); - } - for (let i = 0; i < PRELOAD_WORKERS; i++) { - const w = new SSRWorker(); - if (themeInit) w.setTheme(themeInit); - this.preloadWorkers.push(w); - } - - this.userWorker.ensure().catch(() => {}); - for (const w of this.preloadWorkers) w.ensure().catch(() => {}); - } - - // ── File list ───────────────────────────────────────────────── - - getFiles(): ChangedFile[] { - if (!this.filesCache) { - this.filesCache = getChangedFiles(this.diffArgs, this.cwd); - this.filesByPath.clear(); - for (const f of this.filesCache) this.filesByPath.set(f.path, f); - } - return this.filesCache; - } - - getFileByPath(path: string): ChangedFile | undefined { - this.getFiles(); - return this.filesByPath.get(path); - } - - // ── Preload status ──────────────────────────────────────────── - - private _preloadStatusCache: { cached: number; total: number; preloading: boolean } | null = null; - private _preloadStatusTs = 0; - - get cachedCount(): number { - return this.preloadStatus().cached; - } - - private cacheKey(hash: string): string { - return hash + "\0" + optionsKey(this.renderOptions); - } - - private cacheSet(key: string, html: string): void { - this.cache.set(key, html); - this._preloadStatusCache = null; - } - - // ── Annotations from threads ──────────────────────────────── - - private getAnnotationsForFile(filePath: string): DiffAnnotation[] { - return this.threadStore.forFile(filePath).map(thread => ({ - side: (thread.side === "old" ? "deletions" : "additions") as "deletions" | "additions", - lineNumber: thread.endLine, - })); - } - - /** Invalidate cached HTML for a specific file (e.g. after thread change). */ - private invalidateFile(filePath: string) { - const hash = this.knownHashes.get(filePath); - if (hash) { - this.cache.deleteByPrefix(hash + "\0"); - } - this._preloadStatusCache = null; - } - - // ── User-facing render ──────────────────────────────────────── - - private getContents(file: ChangedFile): { oldContent: string | null; newContent: string | null } { - const cached = this.contentsCache.get(file.path); - if (cached) return cached; - return getOldNewContents(file, this.diffArgs, this.cwd); - } - - async getDiffHTMLForUser(file: ChangedFile): Promise { - this.currentFile = file.path; - let { oldContent, newContent } = this.getContents(file); - ({ oldContent, newContent } = applyWhitespace( - oldContent, newContent, this.renderOptions.ignoreWhitespace, - )); - const hash = contentHash(oldContent, newContent); - this.knownHashes.set(file.path, hash); - - const annotations = this.getAnnotationsForFile(file.path); - const annKey = annotations.length > 0 - ? ":" + annotations.map(a => `${a.side}:${a.lineNumber}`).sort().join(",") - : ""; - const key = this.cacheKey(hash) + annKey; - const cached = this.cache.get(key); - if (cached) return cached; - - const html = await this.userWorker.render( - file, oldContent, newContent, this.renderOptions, annotations, - ); - this.cacheSet(key, html); - return html; - } - - // ── Background preload ──────────────────────────────────────── - - async preloadAll() { - this.aborted = false; - const allFiles = this.getFiles(); - if (allFiles.length === 0) return; - - let files = allFiles; - if (this.currentFile) { - const idx = allFiles.findIndex((f) => f.path === this.currentFile); - if (idx > 0) { - files = [...allFiles.slice(idx), ...allFiles.slice(0, idx)]; - } - } - - const bulkContents = await bulkGetAllContents(files, this.diffArgs, this.cwd); - if (this.aborted) return; - - // Preload syntax highlighting for all file extensions - const extensions = new Set(); - for (const file of files) { - const ext = file.path.split(".").pop()?.toLowerCase(); - if (ext) extensions.add(ext); - } - const langs = [...extensions] - .map((ext) => EXTENSION_TO_LANG[ext]) - .filter(Boolean); - if (langs.length > 0) { - for (const w of this.preloadWorkers) w.preloadLangs(langs); - this.userWorker.preloadLangs(langs); - } - - type WorkItem = { - file: ChangedFile; - oldContent: string | null; - newContent: string | null; - key: string; - annotations: DiffAnnotation[]; - }; - const queue: WorkItem[] = []; - - for (const file of files) { - const raw = bulkContents.get(file.path); - if (!raw) continue; - this.contentsCache.set(file.path, raw); - - let { oldContent, newContent } = raw; - ({ oldContent, newContent } = applyWhitespace( - oldContent, newContent, this.renderOptions.ignoreWhitespace, - )); - const hash = contentHash(oldContent, newContent); - this.knownHashes.set(file.path, hash); - - const annotations = this.getAnnotationsForFile(file.path); - const annKey = annotations.length > 0 - ? ":" + annotations.map(a => `${a.side}:${a.lineNumber}`).sort().join(",") - : ""; - const key = this.cacheKey(hash) + annKey; - if (this.cache.has(key)) continue; - queue.push({ file, oldContent, newContent, key, annotations }); - } - - if (queue.length === 0 || this.aborted) return; - - let nextIdx = 0; - const workerLoop = async (worker: SSRWorker) => { - while (!this.aborted) { - const idx = nextIdx++; - if (idx >= queue.length) break; - const item = queue[idx]; - try { - const html = await worker.render( - item.file, item.oldContent, item.newContent, this.renderOptions, item.annotations, - ); - if (!this.aborted) { - this.cacheSet(item.key, html); - } - } catch {} - } - }; - - await Promise.all(this.preloadWorkers.map((w) => workerLoop(w))); - } - - // ── Options / refresh / reload ──────────────────────────────── - - setOptions(opts: Partial) { - const prev = optionsKey(this.renderOptions); - Object.assign(this.renderOptions, opts); - if (optionsKey(this.renderOptions) !== prev) { - this.aborted = true; - setTimeout(() => this.preloadAll().catch(() => {}), 50); - } - } - - /** Signal that files on disk changed — UI shows a banner instead of auto-refreshing. */ - markStale() { - this.emitEvent({ type: "stale" }); - } - - refresh() { - this.aborted = true; - this.filesCache = null; - this.filesByPath.clear(); - this.knownHashes.clear(); - this.contentsCache.clear(); - this.repoName = getRepoName(this.cwd); - this.branch = getCurrentBranch(this.cwd); - setTimeout(() => this.preloadAll().catch(() => {}), 50); - this.emitEvent({ type: "refresh" }); - } - - /** Swap what this session is showing without restarting the server. */ - reloadWith(newDiffArgs: string[], newDescription: string) { - this.diffArgs = newDiffArgs; - this.description = newDescription; - this.refresh(); - this.emitEvent({ type: "reload", diffArgs: newDiffArgs, description: newDescription }); - } - - preloadStatus(): { cached: number; total: number; preloading: boolean } { - const now = Date.now(); - if (this._preloadStatusCache && now - this._preloadStatusTs < 100) { - return this._preloadStatusCache; - } - const files = this.getFiles(); - const optsKey = optionsKey(this.renderOptions); - let cached = 0; - for (const file of files) { - const hash = this.knownHashes.get(file.path); - if (hash && this.cache.has(hash + "\0" + optsKey)) cached++; - } - this._preloadStatusCache = { - cached, - total: files.length, - preloading: !this.aborted && cached < files.length, - }; - this._preloadStatusTs = now; - return this._preloadStatusCache; - } - - // ── Thread API (delegates to ThreadStore) ───────────────────── - - createThread(data: Parameters[0]) { return this.threadStore.create(data); } - addReply(threadId: string, body: string, author?: Parameters[2]) { return this.threadStore.addReply(threadId, body, author); } - updateThreadStatus(id: string, status: Parameters[1]) { return this.threadStore.updateStatus(id, status); } - editThread(id: string, body: string) { return this.threadStore.edit(id, body); } - deleteThread(id: string) { return this.threadStore.delete(id); } - getThreads(filter?: Parameters[0]) { return this.threadStore.list(filter); } - getThread(id: string) { return this.threadStore.get(id); } - getThreadCount() { return this.threadStore.count; } - - // ── Event system ────────────────────────────────────────────── - - addEventListener(handler: (event: SessionEvent) => void) { - this.eventListeners.add(handler); - } - - removeEventListener(handler: (event: SessionEvent) => void) { - this.eventListeners.delete(handler); - } - - emitEvent(event: SessionEvent) { - for (const handler of this.eventListeners) { - try { handler(event); } catch {} - } - } - - // ── Cleanup ─────────────────────────────────────────────────── - - destroy() { - this.aborted = true; - this.userWorker.kill(); - for (const w of this.preloadWorkers) w.kill(); - } -} diff --git a/aine/dist/index.html b/aine/dist/index.html deleted file mode 100644 index ff24c80..0000000 --- a/aine/dist/index.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - aine - - - - -
- - diff --git a/aine/git.ts b/aine/git.ts deleted file mode 100644 index 3632743..0000000 --- a/aine/git.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { execFileSync, spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import type { FileStatus, ChangedFile } from "./types.js"; - - - -const MAX_BUF = 50 * 1024 * 1024; - -function git(args: string[], cwd: string): string { - return execFileSync("git", args, { - cwd, encoding: "utf8", maxBuffer: MAX_BUF, stdio: ["pipe", "pipe", "pipe"], - }).trim(); -} - -function gitSafe(args: string[], cwd: string): string { - try { return git(args, cwd); } catch { return ""; } -} - -export function isGitRepo(cwd: string): boolean { - try { git(["rev-parse", "--is-inside-work-tree"], cwd); return true; } catch { return false; } -} - -export function getRepoRoot(cwd: string): string { - return git(["rev-parse", "--show-toplevel"], cwd); -} - -export function getRepoName(cwd: string): string { - return getRepoRoot(cwd).split("/").pop() || "unknown"; -} - -export function getCurrentBranch(cwd: string): string { - try { return git(["branch", "--show-current"], cwd); } catch { return git(["rev-parse", "--short", "HEAD"], cwd); } -} - -export function isValidGitRef(ref: string, cwd: string): boolean { - if (ref.includes("..")) return ref.split("..").every(p => p === "" || isValidGitRef(p, cwd)); - try { git(["rev-parse", "--verify", ref], cwd); return true; } catch { return false; } -} - -/** - * Parse CLI/command refs into diffArgs + description. - * Returns `{ error }` if any ref is invalid. - */ -export function parseRefs( - refs: string[], - cwd: string, -): { diffArgs: string[]; description: string } | { error: string } { - for (const ref of refs) { - if (!isValidGitRef(ref, cwd)) return { error: ref }; - } - - if (refs.length === 1) { - return { - diffArgs: [refs[0]], - description: refs[0].includes("..") ? refs[0] : "Changes from " + refs[0], - }; - } - if (refs.length === 2) { - return { - diffArgs: [refs[0] + ".." + refs[1]], - description: refs[0] + ".." + refs[1], - }; - } - return { diffArgs: [], description: "Working tree changes" }; -} - -const STATUS_MAP: Record = { - A: "added", D: "deleted", M: "modified", R: "renamed", C: "copied", T: "modified", -}; - -function parseNumstat(raw: string): Map { - const m = new Map(); - for (const line of raw.split("\n")) { - if (!line.trim()) continue; - const [a, d, ...rest] = line.split("\t"); - const path = rest.join("\t"); - if (!path) continue; - m.set(path, { add: a === "-" ? 0 : +a, del: d === "-" ? 0 : +d }); - } - return m; -} - -export function getChangedFiles(diffArgs: string[], cwd: string): ChangedFile[] { - const files: ChangedFile[] = []; - - if (diffArgs.length === 0) { - const staged = gitSafe(["diff", "--cached", "--name-status", "--no-renames"], cwd); - const unstaged = gitSafe(["diff", "--name-status", "--no-renames"], cwd); - const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd); - const stagedNum = parseNumstat(gitSafe(["diff", "--cached", "--numstat", "--no-renames"], cwd)); - const unstagedNum = parseNumstat(gitSafe(["diff", "--numstat", "--no-renames"], cwd)); - - const seen = new Set(); - for (const line of [...staged.split("\n"), ...unstaged.split("\n")]) { - if (!line.trim()) continue; - const [code, ...rest] = line.split("\t"); - const path = rest[rest.length - 1]; - if (!path || seen.has(path)) continue; - seen.add(path); - const sn = stagedNum.get(path); - const un = unstagedNum.get(path); - files.push({ - path, - status: STATUS_MAP[code[0]] || "modified", - additions: (sn?.add || 0) + (un?.add || 0), - deletions: (sn?.del || 0) + (un?.del || 0), - }); - } - - for (const p of untracked.split("\n")) { - if (!p.trim() || seen.has(p)) continue; - seen.add(p); - let lines = 0; - try { - const content = readFileSync(join(cwd, p), "utf-8"); - lines = content.split("\n").length; - if (content.endsWith("\n")) lines--; - } catch {} - files.push({ path: p, status: "added", untracked: true, additions: lines, deletions: 0 }); - } - } else { - const out = gitSafe(["diff", "--name-status", ...diffArgs], cwd); - const numstat = parseNumstat(gitSafe(["diff", "--numstat", ...diffArgs], cwd)); - - for (const line of out.split("\n")) { - if (!line.trim()) continue; - const [code, ...rest] = line.split("\t"); - const path = rest[rest.length - 1]; - const oldPath = rest.length > 1 ? rest[0] : undefined; - if (!path) continue; - const ns = numstat.get(path); - files.push({ - path, oldPath, - status: STATUS_MAP[code[0]] || "modified", - additions: ns?.add || 0, - deletions: ns?.del || 0, - }); - } - } - - return files.sort((a, b) => a.path.localeCompare(b.path)); -} - -export function getFileAtRef(ref: string, filePath: string, cwd: string): string | null { - try { - return execFileSync("git", ["show", `${ref}:${filePath}`], { - cwd, encoding: "utf8", maxBuffer: MAX_BUF, - stdio: ["pipe", "pipe", "pipe"], - }); - } catch { - return null; - } -} - -export function readWorkingFile(filePath: string, cwd: string): string | null { - try { - return readFileSync(join(cwd, filePath), "utf-8"); - } catch { - return null; - } -} - -/** Parse diffArgs into old/new refs. null newRef means working tree. */ -export function parseDiffRefs(diffArgs: string[]): { oldRef: string; newRef: string | null } { - if (diffArgs.length === 0) return { oldRef: "HEAD", newRef: null }; - const arg = diffArgs[0]; - if (arg.includes("..")) { - const [a, b] = arg.split(".."); - return { oldRef: a || "HEAD", newRef: b || "HEAD" }; - } - return { oldRef: arg, newRef: null }; -} - -export function getOldNewContents( - file: ChangedFile, - diffArgs: string[], - cwd: string, -): { oldContent: string | null; newContent: string | null } { - const { oldRef, newRef } = parseDiffRefs(diffArgs); - - let oldContent: string | null = null; - let newContent: string | null = null; - - if (file.status !== "added" || !file.untracked) { - oldContent = getFileAtRef(oldRef, file.oldPath || file.path, cwd); - } - - if (file.status !== "deleted") { - newContent = newRef - ? getFileAtRef(newRef, file.path, cwd) - : readWorkingFile(file.path, cwd); - } - - return { oldContent, newContent }; -} - -/** Fast hash of old+new content pair, used as cache key. Uses Bun.hash (wyhash). */ -export function contentHash( - oldContent: string | null, - newContent: string | null, -): string { - // Hash each part separately to avoid concatenating large strings. - // Mix h1 into h2's seed so collisions require both parts to collide simultaneously. - const h1 = Bun.hash(oldContent ?? "\0NULL\0"); - const h2 = Bun.hash(newContent ?? "\0NULL\0", Number(h1 & 0xFFFFFFFFn)); - return h1.toString(36) + "_" + h2.toString(36); -} - -/** - * Bulk-read file contents at a given ref using `git cat-file --batch`. - * Returns a Map from "ref:path" → content string (missing entries = file not found). - * ~30× faster than sequential `git show` for many files. - */ -export function bulkGetFilesAtRef( - ref: string, - paths: string[], - cwd: string, -): Promise> { - return new Promise((resolve) => { - if (paths.length === 0) { - resolve(new Map()); - return; - } - - const proc = spawn("git", ["cat-file", "--batch"], { - cwd, - stdio: ["pipe", "pipe", "pipe"], - }); - - const results = new Map(); - let buf = Buffer.alloc(0); - let bufOffset = 0; // track consumed bytes to avoid repeated subarray - let pendingIdx = 0; - - // Each response from cat-file --batch is: - // \n\n (for found objects) - // missing\n (for missing objects) - const processBuffer = () => { - while (pendingIdx < paths.length) { - // Need at least one line (header) - const nlIdx = buf.indexOf(10, bufOffset); // \n - if (nlIdx === -1) break; - - const headerLine = buf.subarray(bufOffset, nlIdx).toString("utf8"); - const query = `${ref}:${paths[pendingIdx]}`; - - if (headerLine.endsWith(" missing")) { - results.set(query, null); - bufOffset = nlIdx + 1; - pendingIdx++; - continue; - } - - // Parse: " blob " - const sizeMatch = headerLine.match(/\s(\d+)$/); - if (!sizeMatch) { - // Unexpected format, skip - results.set(query, null); - bufOffset = nlIdx + 1; - pendingIdx++; - continue; - } - - const size = parseInt(sizeMatch[1], 10); - const contentStart = nlIdx + 1; - const contentEnd = contentStart + size; - // Need size bytes + trailing \n - if (buf.length < contentEnd + 1) break; - - results.set(query, buf.subarray(contentStart, contentEnd).toString("utf8")); - bufOffset = contentEnd + 1; - pendingIdx++; - } - // Compact buffer when we've consumed a lot - if (bufOffset > 65536) { - buf = buf.subarray(bufOffset); - bufOffset = 0; - } - }; - - proc.stdout.on("data", (chunk: Buffer) => { - buf = bufOffset > 0 - ? Buffer.concat([buf.subarray(bufOffset), chunk]) - : Buffer.concat([buf, chunk]); - bufOffset = 0; - processBuffer(); - }); - - proc.on("close", () => { - processBuffer(); - // Mark any remaining as null - for (let i = pendingIdx; i < paths.length; i++) { - results.set(`${ref}:${paths[i]}`, null); - } - resolve(results); - }); - - proc.stderr.on("data", () => {}); - - for (const p of paths) { - proc.stdin.write(`${ref}:${p}\n`); - } - proc.stdin.end(); - }); -} - -/** - * Bulk-read old+new contents for all changed files. - * Uses `git cat-file --batch` for ref-based reads (30× faster than sequential git show). - */ -export async function bulkGetAllContents( - files: ChangedFile[], - diffArgs: string[], - cwd: string, -): Promise> { - const { oldRef, newRef } = parseDiffRefs(diffArgs); - - // Collect paths to read from each ref - const oldPaths: string[] = []; - const newRefPaths: string[] = []; - for (const file of files) { - if (file.status !== "added" || !file.untracked) { - oldPaths.push(file.oldPath || file.path); - } - if (file.status !== "deleted" && newRef) { - newRefPaths.push(file.path); - } - } - - // Bulk-read from git refs in parallel - const [oldResults, newRefResults] = await Promise.all([ - bulkGetFilesAtRef(oldRef, oldPaths, cwd), - newRef ? bulkGetFilesAtRef(newRef, newRefPaths, cwd) : Promise.resolve(new Map()), - ]); - - // Assemble results - const result = new Map(); - for (const file of files) { - let oldContent: string | null = null; - let newContent: string | null = null; - - if (file.status !== "added" || !file.untracked) { - const key = `${oldRef}:${file.oldPath || file.path}`; - oldContent = oldResults.get(key) ?? null; - } - - if (file.status !== "deleted") { - if (newRef) { - const key = `${newRef}:${file.path}`; - newContent = newRefResults.get(key) ?? null; - } else { - newContent = readWorkingFile(file.path, cwd); - } - } - - result.set(file.path, { oldContent, newContent }); - } - - return result; -} - - diff --git a/aine/lru-cache.ts b/aine/lru-cache.ts deleted file mode 100644 index 368ca9b..0000000 --- a/aine/lru-cache.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Simple LRU cache with a max-entry limit. - * Uses a Map (insertion-ordered) — on get/set the entry is - * moved to the end so the oldest entry is always first. - */ -export class LRUCache { - private map = new Map(); - - constructor(private maxSize: number) {} - - get(key: K): V | undefined { - const val = this.map.get(key); - if (val === undefined) return undefined; - // Move to end (most recently used) - this.map.delete(key); - this.map.set(key, val); - return val; - } - - has(key: K): boolean { - return this.map.has(key); - } - - set(key: K, val: V): void { - if (this.map.has(key)) this.map.delete(key); - this.map.set(key, val); - while (this.map.size > this.maxSize) { - // Delete oldest (first) entry - const first = this.map.keys().next().value!; - this.map.delete(first); - } - } - - delete(key: K): boolean { - return this.map.delete(key); - } - - /** Delete all entries whose key starts with the given prefix (string keys only). */ - deleteByPrefix(prefix: string): number { - let count = 0; - for (const key of [...this.map.keys()]) { - if (typeof key === "string" && key.startsWith(prefix)) { - this.map.delete(key); - count++; - } - } - return count; - } - - get size(): number { - return this.map.size; - } - - clear(): void { - this.map.clear(); - } -} diff --git a/aine/notes/diffity-analysis.md b/aine/notes/diffity-analysis.md deleted file mode 100644 index 77dfe7d..0000000 --- a/aine/notes/diffity-analysis.md +++ /dev/null @@ -1,127 +0,0 @@ -# Diffity vs. Aine – Analýza - -> Datum: 2026-03-26 -> Zdroj: https://github.com/kamranahmedse/diffity - -## Co aine už má (a dělá dobře) - -- ✅ GitHub-style diff viewer (split/unified) s SSR přes `@pierre/diffs` -- ✅ Session management (multi-repo, port discovery, PID tracking) -- ✅ Komentáře s severity (`must-fix`, `suggestion`, `nit`) -- ✅ CLI pro agenty (`aine session comment add/list/resolve/rm/clear`) -- ✅ SSE live updates (komentáře, navigace, reload) -- ✅ Background preloading s worker poolem + `git cat-file --batch` -- ✅ Theme system (shiki-based, TOML config, per-repo override) -- ✅ Keyboard shortcuts (vim-style) -- ✅ File watcher s auto-refresh -- ✅ TOML config (globální + per-repo) - ---- - -## Features z diffity, které by se hodily do aine - -### 1. 🔥 Guided Code Tours (nejvyšší přidaná hodnota) - -Diffity umí `diffity-tour` — agent projde codebase a vytvoří narativní walkthrough s highlighted code regiony. Aine nic takového nemá. Využití: -- Onboarding nových devů -- Vysvětlení architektury -- Code review s kontextem - -**Jak na to:** Přidat tours model (SQLite/JSON), API routes (`/api/tours`), UI panel se step navigací, a `aine session tour` CLI subcommand. - -### 2. 🔥 Thread model místo flat comments - -Diffity má plnohodnotné **thread** systém: -- Komentáře mají **replies** (vlákna konverzací) -- **Multi-line selection** (startLine + endLine) — aine má jen single line -- **Anchor content** — ukládá obsah řádku pro drift detection -- Status: `open` / `resolved` / `dismissed` (aine nemá `dismissed`) -- **General comments** (diff-level, bez souboru) - -**Aine teď:** Flat `Comment` s jedním `line`, bez reply chain, bez anchor content. - -### 3. 🔥 `aine agent` CLI (dedikovaný agent subcommand) - -Diffity má `diffity agent diff/comment/resolve/dismiss/reply/tour-start/tour-step/tour-done` — čistý CLI interface pro agenty. Aine to dělá přes `aine session comment add`, což je verbose a méně přehledné. - -**Lépe:** Přidat `aine agent` s krátkými příkazy: -``` -aine agent diff -aine agent comment --file X --line 5 --body "..." -aine agent resolve -aine agent reply --body "..." -aine agent tour-start --topic "..." -``` - -### 4. ⚡ SQLite persistence (better-sqlite3) - -Diffity ukládá review sessions, threads, tours do SQLite s WAL mode. Aine drží vše **in-memory** v `Map` — ztráta dat po restartu. - -**Výhoda SQLite:** Přetrvává restart, lepší querying, funguje i pro tours. - -Diffity schema: -```sql -CREATE TABLE review_sessions (id, ref, head_hash, created_at); -CREATE TABLE comment_threads (id, session_id, file_path, side, start_line, end_line, status, anchor_content, ...); -CREATE TABLE comments (id, thread_id, author_name, author_type, body, created_at); -CREATE TABLE tours (id, session_id, topic, body, status, created_at); -CREATE TABLE tour_steps (id, tour_id, sort_order, file_path, start_line, end_line, body, annotation, created_at); -``` - -### 5. ⚡ Stale diff detection + banner - -Diffity polluje fingerprint diffu každé 3s a ukáže banner "diff is stale, refresh" když se soubory změní. Aine má file watcher ale auto-refreshuje — nemá UI indikátor, že se změnil podkladový kód. - -### 6. ⚡ Context expansion (expandable gaps) - -Diffity umí **expand context** around hunks — klikneš na gap a zobrazí dalších 20 řádků. Aine má jen toggle `expandUnchanged` (vše nebo nic). - -Klíčový soubor: `packages/ui/src/lib/context-expansion.ts` — počítá gaps mezi hunky a umožňuje inkrementální expand po 20 řádcích. - -### 7. ⚡ File tree browser (`diffity tree`) - -Diffity umí `diffity tree` — procházení celého repo, ne jen changed files. Komentáře na jakýkoli soubor. Aine zobrazuje jen soubory v diffu. - -### 8. 💡 GitHub PR integrace - -Diffity umí: -- Stáhnout PR přes `gh` CLI -- Push/pull komentářů na GitHub -- Obousměrná sync review komentářů - -Klíčový soubor: `packages/github/src/pr.ts` — `pullComments()`, `pushComments()`, `getFiles()`. - -### 9. 💡 Word-level diff highlighting - -Diffity má vlastní `@diffity/parser` s **word-diff** — zvýrazní přesně která slova se změnila v řádku, ne jen celý řádek. - -### 10. 💡 Line selection (drag-to-select) - -Diffity umí kliknout a táhnout pro výběr **rozsahu řádků** pro komentář (mouse drag s anchor line tracking). Aine má jen single-line. - -Klíčový soubor: `packages/ui/src/hooks/use-line-selection.ts`. - ---- - -## Co aine dělá LÍP než diffity - -| Oblast | Aine | Diffity | -|--------|------|---------| -| **Tech stack** | Svelte 5 + Bun (lehký, single binary) | React + Node + monorepo (těžší) | -| **SSR rendering** | Worker pool s `@pierre/diffs` | Klientský rendering | -| **Preloading** | `git cat-file --batch` bulk read + background SSR | On-demand | -| **Single file deploy** | `vite-plugin-singlefile` — vše v jednom HTML | Separate build | -| **Config** | TOML config (global + per-repo) | Žádný config soubor | -| **Licence** | MIT | PolyForm Shield (omezující!) | -| **Závislosti** | Minimal (Bun built-in) | better-sqlite3, React, commander, picocolors... | - ---- - -## Prioritní doporučení (co implementovat) - -1. **Thread model s replies + multi-line selection** — největší UX improvement -2. **Context expansion** (kliknutí na gap pro zobrazení okolních řádků) -3. **Guided code tours** — unikátní feature, obrovská přidaná hodnota -4. **SQLite persistence** — spolehlivost dat -5. **`aine agent` CLI** — čistší API pro agenty -6. **Stale diff banner** — lepší UX než silent auto-refresh diff --git a/aine/notes/plan.md b/aine/notes/plan.md deleted file mode 100644 index cc92902..0000000 --- a/aine/notes/plan.md +++ /dev/null @@ -1,88 +0,0 @@ -# Aine Refactoring Plan - -**Cíl:** Maximální udržitelnost a budoucí rozšiřitelnost kódu. - -## Analýza - -| Soubor | Řádky | Problém | -|--------|-------|---------| -| `DiffView.svelte` | 581 | God component — cache, hunks, komentáře, gutter eventy | -| `diff-session.ts` | 504 | Míchá diff cache, thread CRUD, event system, preload | -| `CommentsPanel.svelte` | 482 | Scroll sync polluje `setInterval(500ms)` | -| `Sidebar.svelte` | 386 | OK, ale tree logika patří do lib/ | -| `git.ts` | 363 | Mrtvé re-exporty typů | -| `routes.ts` | 340 | OK | -| `session-cli.ts` | 299 | OK | -| `cli.ts` | 279 | OK | - -## 1. Odstranit mrtvý kód a zbytečné re-exporty - -**Problém:** Typy se re-exportují přes 3 soubory místo přímého importu z `types.ts`. - -- `diff-session.ts` — `export type { RenderOptions, Severity, ... } from "./types.js"` → nikdo neimportuje typy z diff-session -- `ssr-worker-client.ts` — `export type { RenderOptions } from "./types.js"` → nepoužito -- `git.ts` — `export type { FileStatus, ChangedFile } from "./types.js"` → nepoužito -- `theme.ts` — `export type { ThemeColors as AineUIColors }` → alias nikdy nepoužit - -## 2. Extrahovat ThreadStore z DiffSession - -**Problém:** `DiffSession` (504 řádků) dělá příliš mnoho — diff cache + thread CRUD + eventy + preload. - -**Řešení:** Nový `thread-store.ts` (~120 řádků): -- `createThread()`, `addReply()`, `updateThreadStatus()`, `editThread()`, `deleteThread()` -- `getThreads()`, `getThread()`, `findThread()`, `getThreadCount()` -- Vlastní event emitter pro thread změny -- DiffSession deleguje na ThreadStore - -## 3. Extrahovat CommentInput z DiffView - -**Problém:** `DiffView.svelte` (581 řádků) — comment input logika zabírá ~150 řádků. - -**Řešení:** Nový `CommentInput.svelte`: -- Comment form (textarea, submit, cancel) -- Positioning logic (`positionInputAtLine`) -- Edit mode handling -- DiffView jen předává props - -## 4. Extrahovat tree-building logiku ze Sidebar - -**Problém:** Sidebar obsahuje ~100 řádků pure tree-building logiky vmíchaného s UI. - -**Řešení:** Nový `src/lib/file-tree.ts`: -- `buildTree()`, `squash()`, `collectVisibleItems()`, `collectAllFolderKeys()` -- `findFolderForPath()`, `findCollapsedFolderForPath()` -- Sidebar se zjednoduší na čistý UI - -## 5. Extrahovat EXTENSION_TO_LANG constant - -**Problém:** Hardcoded mapping v preloadAll() uprostřed business logiky. - -**Řešení:** Přesunout do `types.ts` nebo nový `constants.ts` — sdílené mezi diff-session a ssr-worker. - -## 6. Vyčistit CommentsPanel scroll sync - -**Problém:** `setInterval(500ms)` polling pro detekci scroll elementu. - -**Řešení:** Předat scrollEl jako prop z App.svelte (je vždy dostupný) nebo použít `$effect` na `selectedPath`. - -## 7. Zjednodušit DiffView client cache - -**Problém:** DiffView má vlastní inline LRU cache (~30 řádků) duplikující backend pattern. - -**Řešení:** Reuse `LRUCache` class — přesunout do `src/lib/lru-cache.ts` (symlink/copy pro frontend). - -## Pořadí provedení - -1. Mrtvý kód (bezpečné, žádné dependencies) -2. `thread-store.ts` extrakce -3. `src/lib/file-tree.ts` extrakce -4. `CommentInput.svelte` extrakce -5. CommentsPanel scroll sync cleanup -6. Constants extrakce -7. Client LRU cache reuse - -## Rizika - -- Testy musí projít po každém kroku -- Svelte 5 `bind:this` a `$props()` mají specifické patterny -- SSR worker komunikace nesmí být narušena diff --git a/aine/notes/review-workflow-brainstorm.md b/aine/notes/review-workflow-brainstorm.md deleted file mode 100644 index 45cbfa2..0000000 --- a/aine/notes/review-workflow-brainstorm.md +++ /dev/null @@ -1,503 +0,0 @@ -# Aine: AI Code Review Workflow — Brainstorm - -## Současný stav - -Aine je diff viewer s inline komentáři, který AI agent ovládá přes CLI (`aine session *`). - -**Umí:** diff rendering, thready s severity/status, file watching, preload cache, themes, vim klávesy, SSE live updates. - -**Problém:** Je to viewer, ne review tool. Chybí workflow — kdo co dělá, kdy je hotovo, jak se feedback vrací agentovi. - ---- - -## Cílová vize - -Aine = **review loop mezi člověkem a AI agentem**. - -``` -Agent pracuje → checkpoint → AINE review → feedback → agent fixuje → re-review → approve → done -``` - -Ne jen "podívej se na diff", ale celý řízený proces s jasným stavem a výstupem. - ---- - -## Klíčové features (seřazeno podle priority) - -### P0: Approval workflow + feedback loop - -**Problém:** Dnes agent udělá změny, uživatel se podívá, ale není formální "approve / request changes" krok. Agent neví, jestli je review hotové. - -**Návrh:** - -Review session má globální stav: -``` -pending → in_review → changes_requested → approved / rejected -``` - -UI dole (nebo v toolbaru): -- **"Request Changes"** — uživatel napíše souhrnný feedback, stav → `changes_requested` -- **"Approve"** — stav → `approved` -- **"Reject"** — stav → `rejected` (zahoď všechno) - -Agent se dozví přes: -- `aine session status --repo .` → vrátí stav + feedback -- SSE event `review-verdict` → agent čeká na verdict -- Exit code? (aine blokuje dokud uživatel nerozhodne?) - -**Otevřené otázky:** -- Má aine blokovat agenta (agent spustí `aine review` a čeká na výsledek)? -- Nebo je to async (agent se pravidelně ptá)? -- Nebo obojí (CLI s `--wait` flag)? - ---- - -### P1: File-level review status - -**Problém:** Při 40 souborech nevíš, které jsi reviewoval, které jsou OK, které potřebují práci. - -**Návrh:** - -Každý soubor má status: -``` -unreviewed → viewed → approved | needs_work -``` - -Sidebar ukazuje stav: -``` -☐ auth.ts (unreviewed) -👁 db.ts (viewed — otevřel ale neoznačil) -✅ config.ts (approved) -⚠ api.ts (needs work — má open komentáře) -``` - -Automatické přechody: -- `unreviewed → viewed` — když uživatel soubor otevře a stráví tam >2s (nebo scrollne dolů) -- `viewed → needs_work` — automaticky když přidá open komentář -- `needs_work → approved` — když všechny komentáře resolved + uživatel explicitně approves - -Klávesová zkratka: `a` = approve current file, `x` = mark needs work - -Progress bar v toolbaru: `Review: 7/12 files ████░░░░ 58%` - ---- - -### P2: Review summary / dashboard - -**Problém:** Otevřeš aine a vidíš seznam souborů. Nevíš co se dělo, jaký byl intent, jaký je stav. - -**Návrh:** - -Summary panel (horní část, collapsible, nebo jako první "soubor" v sidebar): - -``` -┌─────────────────────────────────────────────┐ -│ 📋 Review: Fix auth race condition │ -│ │ -│ 12 files changed +340 / -520 │ -│ Review: 3/12 ██░░░░ │ 2 open comments │ -│ │ -│ Agent summary: │ -│ "Refactored auth middleware to use mutex. │ -│ Key changes: auth.ts (new lock), db.ts │ -│ (connection pooling), api.ts (retry logic)"│ -│ │ -│ Checks: ✅ types ✅ lint ⚠ tests (1 fail) │ -└─────────────────────────────────────────────┘ -``` - -Odkud summary pochází: -- Agent poskytne přes CLI: `aine session meta --repo . --summary "..." --task "..."` -- Nebo automaticky z agent session logu -- Check results: agent pošle přes CLI nebo aine sám spustí configured commands - ---- - -### P3: Agent context (per-file reasoning) - -**Problém:** Vidíš CO se změnilo, ale ne PROČ. Agent měl důvod — ale ten je ztracený v session logu. - -**Návrh:** - -Agent může ke každému souboru přidat "change note": -```bash -aine session annotate --repo . --file auth.ts --note "Added mutex lock to prevent concurrent token refresh. Previous implementation could issue duplicate tokens under load." -``` - -V UI se zobrazí jako collapsible banner nad diffem: -``` -┌─ 🤖 Agent note ───────────────────────────────┐ -│ Added mutex lock to prevent concurrent token │ -│ refresh. Previous implementation could issue │ -│ duplicate tokens under load. │ -└────────────────────────────────────────────────┘ -``` - -Alternativa: agent to dá jako první komentář typu "context" (ne review komentář, ale vysvětlení). - ---- - -### P4: Smart file ordering - -**Problém:** Abecední řazení. Config soubory a testy se míchají s core logic. - -**Návrh:** - -Výchozí řazení podle "review importance": -1. Core source files s velkými změnami -2. Core source files s malými změnami -3. Test files -4. Config / generated / lock files - -Heuristika: -- Velikost změny (additions + deletions) -- Typ souboru (`.ts` > `.json` > `.lock`) -- Path pattern (`src/` > `test/` > `config/`) -- Má open komentáře → boost - -Konfigurovatelné přes `.aine.toml` nebo `aine.config.ts`: -```toml -[review.ordering] -boost = ["src/**"] -demote = ["*.lock", "*.generated.*", "test/**"] -``` - -Sidebar grouping: -``` -── Core changes (5) ── - auth.ts +42 -18 - db.ts +15 -8 -── Tests (4) ── - auth.test.ts +30 -── Config (3) ── - package.json +2 -1 -``` - ---- - -### P5: Inline "explain this" - -**Problém:** Stojíš na řádku, nerozumíš proč ho agent změnil. - -**Návrh:** - -Klávesa `?` na řádku (nebo right-click → "Ask agent") → pošle request agentovi: -```bash -# Agent dostane: -aine session event --repo . -# → { type: "explain-request", file: "auth.ts", line: 42, context: "..." } -``` - -Agent odpoví komentářem: -```bash -aine session comment reply --repo . --summary "This line..." --author agent -``` - -V UI: loading indikátor na řádku → agent response se objeví jako inline annotation. - -**Otevřená otázka:** Jak agent ví, že má odpovědět? Push (SSE event) vs. pull (polling)? - ---- - -### P6: Automated pre-review - -**Problém:** Před review bys chtěl vědět jestli kód aspoň projde základními checky. - -**Návrh:** - -Configurable checks v `.aine.toml`: -```toml -[[review.checks]] -name = "TypeScript" -command = "npx tsc --noEmit" - -[[review.checks]] -name = "Lint" -command = "npx eslint ." - -[[review.checks]] -name = "Tests" -command = "npm test" -``` - -Aine spustí checks na pozadí, výsledky zobrazí: -- V summary panelu: ✅/❌ per check -- Inline: pokud check output obsahuje file:line, přiřadit k souboru - -Optional: "AI pre-review" check — spustí configured AI prompt na diff. - ---- - -### P7: Granular revert - -**Problém:** Vidíš problém v jednom souboru/hunku. Chceš vrátit jen to. - -**Návrh:** - -Per-file akce v sidebar: "Revert file" (git checkout -- file) -Per-hunk akce v diff view: "Revert hunk" (git checkout -p equivalent) - -Potvrzovací dialog. Po revertu se diff automaticky refreshne. - ---- - -## Otevřené designové otázky - -1. **Jak se review session vytváří?** - - `aine review` (nový příkaz, implikuje review mode)? - - `aine diff` + přepnutí do review mode? - - Agent vytvoří: `aine session review-request --repo . --summary "..."`? - -2. **Persistance review stanu?** - - Dnes je vše in-memory. Když aine restartneš, ztratíš komentáře. - - Ukládat do `.aine/reviews/` v repo? - - Nebo do global store `~/.aine/`? - -3. **Multi-agent review?** - - Může víc agentů reviewovat stejný changeset? - - Agent A udělá změny → Agent B reviewuje → člověk finálně schválí? - -4. **Integrace s git workflow?** - - Po approve: auto-commit? auto-push? create PR? - - Nebo je to na uživateli? - -5. **Review history?** - - Chceš se vrátit k minulým review sessions? - - "Minule jsem tohle zamítl, agent to fixnul, chci vidět co se změnilo" - ---- - -## Navržená architektura (high level) - -``` -┌────────────┐ CLI ┌──────────────┐ HTTP/SSE ┌──────────┐ -│ AI Agent │ ◄────────────► │ Aine Server │ ◄──────────────► │ Aine UI │ -│ (pi, ...) │ session cmds │ │ │ (browser) │ -└────────────┘ │ - DiffSession│ └──────────┘ - │ - ReviewState│ - │ - Checks │ - └──────────────┘ -``` - -Nové server-side koncepty: -- **ReviewState** — globální stav review (pending/in_review/approved/...) -- **FileReviewStatus** — per-file stav (unreviewed/viewed/approved/needs_work) -- **ReviewMeta** — task description, agent summary, check results -- **ReviewVerdict** — finální rozhodnutí + feedback message - -Nové CLI příkazy: -```bash -aine session meta --repo . --summary "..." --task "..." -aine session annotate --repo . --file auth.ts --note "..." -aine session status --repo . # → { state, feedback, files } -aine session await-verdict --repo . # blocks until user decides -aine session checks --repo . --name "Tests" --status pass|fail --output "..." -``` - -Nové API endpoints: -``` -POST /api/review/verdict { action: "approve"|"request_changes"|"reject", feedback: "..." } -GET /api/review/status → { state, feedback, fileSummary, checks } -PATCH /api/files/:path/status { status: "approved"|"needs_work" } -POST /api/review/meta { summary, task, context } -POST /api/review/checks { name, status, output } -``` - ---- - -## Experimentální / netypické nápady - -### 🎬 Replay mode — přehrání agentova procesu - -Ne jen finální diff, ale timeline celého agentova workflow. Agent udělal 47 kroků — vidíš: -- Krok 12: přečetl auth.ts, rozhodl se refaktorovat -- Krok 15: první pokus, napsal to s Promise.all -- Krok 22: testy spadly, přepsal na mutex -- Krok 31: opravil edge case - -Klikneš na kterýkoliv krok → vidíš diff *v tom momentě*. Jako git bisect, ale na úrovni agentovy práce. Pochopíš PROČ je finální kód takový — vidíš slepé uličky, které agent zkusil a zahodil. Pochopíš reasoning skrz proces, ne jen výsledek. - -Zdroj dat: agent session log (pi sessions mají JSONL s každým krokem). - ---- - -### 🔬 Semantic diff — ne řádky, ale záměr - -Normální diff: "+3 -5 řádků v auth.ts". Semantic diff: -- "Přidána nová funkce `acquireLock()`" -- "Změněn return type `getUser()` z `User` na `User | null`" -- "Odstraněn dead code: nepoužívaný import a helper" - -Strukturální pohled na to co se *sémanticky* změnilo. Tree-sitter AST diff místo line diff. Reviewer vidí intent, ne textové šumy (přeformátování, přesuny, importy). - -Implementace: tree-sitter parse old + new → AST diff → human-readable summary per file. - ---- - -### 🧪 "What if" sandbox - -Stojíš na změně, říkáš si "co kdyby to agent udělal jinak?" Klikneš "What if..." → napíšeš alternativní přístup → aine pošle agentovi kontext + tvůj návrh → agent vygeneruje alternativní implementaci → vidíš side-by-side: agentova verze vs. alternativa. - -Review se změní z "approve/reject" na **"choose the best approach"**. Kolaborativní design, ne jednosměrný gate. - ---- - -### 🕸️ Impact graph — blast radius změny - -Změnil se interface v `types.ts`. Aine ukáže graf závislostí: -- Které soubory ten interface importují -- Které z nich agent taky změnil (✅ consistent) -- Které NE (⚠️ potenciální breaking change) - -Vizuální "blast radius" každé změny. Reviewer okamžitě vidí jestli agent zapomněl updatnout nějakého consumera. - -Implementace: statická analýza importů + cross-reference s changesetem. - ---- - -### 🎯 Confidence heatmap - -Agent při generování kódu vnitřně "ví" kde si je jistý a kde hádá. Aine to vizualizuje přímo v diffu: -- Řádky s vysokou confidence = normální zobrazení -- Řádky s nízkou confidence = žluté zvýraznění, "⚠ agent uncertain" - -Reviewer ví kde soustředit pozornost — nemusí číst 500 řádků, koukne na 20 žlutých. - -Zdroj: agent by musel poskytnout confidence metadata per-line/per-hunk (nový protocol). - ---- - -### ⏪ Regression anchor — kvantitativní srovnání - -Před review Aine automaticky spustí testy/checks na *původním* i *novém* kódu. Ne jen "testy prošly" ale: -- "Test X předtím trval 50ms, teď 800ms" → perf regression -- "Coverage klesla z 85% na 72% v auth module" -- "Bundle size: 142KB → 158KB (+11%)" - -Kvantitativní before/after, ne jen pass/fail. Čísla, ne jen zelená/červená. - ---- - -### 🗣️ Conversational review - -Místo komentářů na řádcích — chat panel přímo v Aine. "Vysvětli mi proč jsi přidal ten mutex" → agent odpoví s odkazem na konkrétní řádky (`auth.ts:42-48`) → klikneš na odkaz → skočíš tam v diffu → pokračuješ v konverzaci. - -Review jako dialog, ne jako formulář s komentáři. Přirozenější pro interakci s AI. - -Hybridní model: chat pro diskuzi + komentáře pro tracking (z chatu se dají "pinovat" formální review komentáře). - ---- - -### 📸 Snapshot review — velké změny po kouscích - -Agent dělá velkou změnu (100+ souborů). Místo jednoho mamutího review — agent po každém logickém kroku udělá "snapshot": - -1. "Snapshot 1: Extracted shared types" (5 files) → ✅ approved -2. "Snapshot 2: Migrated auth module" (12 files) → ⚠ needs work -3. "Snapshot 3: Updated tests" (8 files) → ☐ not reviewed - -Reviewuješ po stravitelných kouscích. Každý snapshot má vlastní diff, summary, komentáře. Approve jde po snapshots — partial approve je OK. - -Agent řídí granularitu: `aine session snapshot --repo . --name "Extracted types" --files src/types/*.ts` - ---- - -### 🛡️ Trust levels — ne všechno potřebuje review - -Opak klasického review. Definuješ pravidla co agent smí dělat *bez review*: - -```toml -[trust] -auto_approve = ["*.md", "*.json", "test/**"] -always_review = ["**/auth/**", "**/payment/**", "*.sql"] -flag_if = "new_dependency" # nový import/package → vždy flagni -``` - -Aine auto-approves low-risk soubory, zvýrazní jen ty, které opravdu potřebují lidské oči. Místo 40 souborů reviewuješ 8. Zbytek je transparentně schválený s logem proč. - -Postupně se trust model učí: "za poslední měsíc jsi nikdy neměl problém s test soubory → navrhuju auto-approve." - ---- - -### 🧬 Behavioral diff — co se změnilo v chování, ne v textu - -Textový diff říká "řádek 42 se změnil". Behavioral diff říká: -- "Funkce `getUser()` teď throwuje místo return null" -- "API endpoint `/auth` teď přijímá nový query param `force`" -- "Timeout se změnil z 5s na 30s" - -Extrahované z AST + runtime analýzy. Jako changelog, ale automaticky a přesně. - -Bonusový level: "Tady jsou 3 callery `getUser()` — žádný z nich nehandluje throw. 💥" - ---- - -### 🎰 Adversarial mode — zkus to rozbít - -Před review Aine automaticky: -1. Vezme nový kód -2. Nechá jiný AI model, aby se ho pokusil rozbít — edge cases, weird inputs, race conditions -3. Výsledky ukáže inline: "Tohle spadne když `userId` je empty string" - -Reviewer nemusí vymýšlet edge cases — dostane je předžvýkané. Pokud adversarial model nic nenajde, máš vyšší confidence. - ---- - -### 📉 Review budget — máš 10 minut - -Realita: nemáš čas reviewovat 500 řádků pečlivě. Aine to ví. - -Nastavíš si "review budget" (čas nebo effort level). Aine spočítá risk score per soubor a navrhne: -``` -⏱ 10-minute review plan: -1. auth.ts (5 min) — security-critical, large change -2. db.ts (3 min) — new connection pooling logic -3. api.ts (2 min) — skim, mostly mechanical -── auto-approved (low risk) ── -4-12. config, tests, types — agent handled correctly -``` - -Review jako triage, ne jako čtení románu. - ---- - -### 🔁 Pattern consistency checker - -Agent použil pattern X na 3 místech, ale na 4. místě pattern Y. Proč? Buď je to bug, nebo má důvod — ale reviewer by to měl vidět: - -``` -⚠ Inconsistency detected: - src/handlers/auth.ts:15 → uses try/catch - src/handlers/users.ts:22 → uses try/catch - src/handlers/orders.ts:18 → uses try/catch - src/handlers/billing.ts:31 → uses .catch() ← different pattern -``` - -Cross-file pattern analýza. Najde inkonsistence, které lidské oko snadno přehlédne. - ---- - -### 📦 Dependency audit — co agent přitáhl - -Agent přidal `npm install cool-lib`. Aine automaticky: -- Velikost: +340KB minified -- Maintainer: 1 osoba, last commit 8 months ago -- Known CVEs: 0 -- License: MIT ✅ -- Alternativy: `better-lib` (smaller, more maintained) -- Opravdu to potřebuje? Použil z toho 1 funkci, dá se nahradit 5 řádky - -Supply chain review zabudovaný do code review. - ---- - -### 🧠 Review memory — Aine se učí co tě zajímá - -Aine si pamatuje tvoje minulé review komentáře. Vzorce: -- "Vždy komentuje chybějící error handling" -- "Vždy flaguje any type v TypeScriptu" -- "Nikdy nekomentuje import ordering" - -Příště automaticky: "Na základě tvých minulých review — tady jsou 4 místa kde pravděpodobně budeš chtít komentovat" — a rovnou ti je předpřipraví. - -Personalizovaný review assistant, ne generic linter. diff --git a/aine/package.json b/aine/package.json deleted file mode 100644 index ed4806c..0000000 --- a/aine/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "aine", - "version": "0.1.0", - "description": "Agent-agnostic diff viewer and code review tool", - "license": "MIT", - "bin": { - "aine": "./cli.ts" - }, - "type": "module", - "scripts": { - "build": "bun ./node_modules/.bin/vite build", - "dev": "bun --watch cli.ts & bun ./node_modules/.bin/vite dev & wait", - "start": "bun cli.ts", - "test": "vitest run --config tests/vitest.config.ts", - "test:watch": "vitest --config tests/vitest.config.ts", - "test:api": "vitest run --config tests/vitest.config.ts api/", - "test:unit": "vitest run --config tests/vitest.config.ts unit/", - "test:e2e": "bunx playwright test --config tests/playwright.config.ts" - }, - "dependencies": { - "@pierre/diffs": "latest" - }, - "devDependencies": { - "@playwright/test": "^1.58.2", - "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@vitest/coverage-v8": "^4.1.2", - "svelte": "^5.55.0", - "vite": "^8.0.2", - "vite-plugin-singlefile": "^2.3.2", - "vitest": "^4.1.2" - } -} diff --git a/aine/routes.ts b/aine/routes.ts deleted file mode 100644 index 37e4e11..0000000 --- a/aine/routes.ts +++ /dev/null @@ -1,340 +0,0 @@ -/** - * API route handlers for the Aine HTTP server. - * Each handler is a pure function: (req, res, ctx) → void. - */ - -import type { IncomingMessage, ServerResponse } from "node:http"; -import type { DiffSession } from "./diff-session.js"; -import type { ThemeColors } from "./types.js"; - -// ── HTTP helpers ────────────────────────────────────────────── - -export function json(res: ServerResponse, data: unknown) { - res.writeHead(200, { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - }); - res.end(JSON.stringify(data)); -} - -export function error(res: ServerResponse, code: number, msg: string) { - res.writeHead(code, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: msg })); -} - -export function parseBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let body = ""; - req.on("data", (c: Buffer) => { body += c; }); - req.on("end", () => { - try { resolve(JSON.parse(body)); } - catch { reject(new Error("Invalid JSON")); } - }); - }); -} - -// ── Route context ───────────────────────────────────────────── - -export interface RouteContext { - session: DiffSession; - themeUI: ThemeColors; - cwd: string; - diffArgs: string[]; - description: string; -} - -// ── Route handler type ──────────────────────────────────────── - -type Handler = (req: IncomingMessage, res: ServerResponse, ctx: RouteContext) => Promise | void; - -// ── Static routes (exact path match) ────────────────────────── - -const staticRoutes: Record> = { - "/api/theme": { - GET: (_req, res, ctx) => json(res, ctx.themeUI), - }, - - "/api/info": { - GET: (_req, res, ctx) => { - const files = ctx.session.getFiles(); - json(res, { - name: ctx.session.repoName, - branch: ctx.session.branch, - description: ctx.description, - fileCount: files.length, - cached: ctx.session.cachedCount, - total: files.length, - }); - }, - }, - - "/api/files": { - GET: (_req, res, ctx) => json(res, ctx.session.getFiles()), - }, - - "/api/options": { - GET: (_req, res, ctx) => json(res, ctx.session.renderOptions), - POST: async (req, res, ctx) => { - try { - const opts = await parseBody(req); - ctx.session.setOptions(opts); - json(res, ctx.session.renderOptions); - } catch { - error(res, 400, "Invalid JSON"); - } - }, - }, - - "/api/refresh": { - GET: (_req, res, ctx) => { ctx.session.refresh(); json(res, { ok: true }); }, - POST: (_req, res, ctx) => { ctx.session.refresh(); json(res, { ok: true }); }, - }, - - "/api/preload-status": { - GET: (_req, res, ctx) => json(res, ctx.session.preloadStatus()), - }, - - "/api/preload-stream": { - GET: (req, res, ctx) => { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", - }); - const send = () => { - const status = ctx.session.preloadStatus(); - res.write(`data: ${JSON.stringify(status)}\n\n`); - if (!status.preloading) { clearInterval(timer); res.end(); } - }; - send(); - const timer = setInterval(send, 300); - req.on("close", () => clearInterval(timer)); - }, - }, - - // ── Session control (for agents) ──────────────────────────── - - "/api/session/context": { - GET: (_req, res, ctx) => { - const files = ctx.session.getFiles(); - json(res, { - repo: ctx.cwd, - repoName: ctx.session.repoName, - branch: ctx.session.branch, - description: ctx.description, - diffArgs: ctx.diffArgs, - selectedFile: ctx.session.currentFile, - files: files.map(f => ({ - path: f.path, status: f.status, - additions: f.additions, deletions: f.deletions, - })), - renderOptions: ctx.session.renderOptions, - threadCount: ctx.session.getThreadCount(), - }); - }, - }, - - "/api/session/navigate": { - POST: async (req, res, ctx) => { - try { - const { file, hunk, line } = await parseBody(req); - ctx.session.currentFile = file; - ctx.session.emitEvent({ type: "navigate", file, hunk, line }); - json(res, { ok: true }); - } catch { - error(res, 400, "Invalid JSON"); - } - }, - }, - - "/api/session/reload": { - POST: async (req, res, ctx) => { - try { - const { diffArgs: newDiffArgs, description: newDesc } = await parseBody(req); - ctx.session.reloadWith(newDiffArgs, newDesc || ""); - json(res, { ok: true }); - } catch { - error(res, 400, "Invalid JSON"); - } - }, - }, - - // ── Threads (list / create) ───────────────────────────────── - - "/api/threads": { - GET: (req, res, ctx) => { - const url = new URL(req.url || "/", "http://localhost"); - const file = url.searchParams.get("file") ?? undefined; - const status = url.searchParams.get("status") as any ?? undefined; - json(res, ctx.session.getThreads({ file, status })); - }, - POST: async (req, res, ctx) => { - try { - const data = await parseBody(req); - json(res, ctx.session.createThread(data)); - } catch (e) { - error(res, 400, String(e)); - } - }, - }, - - // ── SSE events ────────────────────────────────────────────── - - "/api/events": { - GET: (req, res, ctx) => { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", - }); - const handler = (event: any) => { - res.write(`data: ${JSON.stringify(event)}\n\n`); - }; - ctx.session.addEventListener(handler); - req.on("close", () => ctx.session.removeEventListener(handler)); - }, - }, -}; - -// ── Dynamic routes (regex match) ────────────────────────────── - -interface DynamicRoute { - pattern: RegExp; - method: string; - handler: (req: IncomingMessage, res: ServerResponse, ctx: RouteContext, match: RegExpMatchArray) => Promise | void; -} - -const dynamicRoutes: DynamicRoute[] = [ - // /api/diff/:path - { - pattern: /^\/api\/diff\/(.+)$/, - method: "GET", - handler: async (_req, res, ctx, m) => { - const filePath = decodeURIComponent(m[1]); - const file = ctx.session.getFileByPath(filePath); - if (!file) { error(res, 404, "Not found"); return; } - try { - const html = await ctx.session.getDiffHTMLForUser(file); - json(res, { file, html }); - } catch (e) { - error(res, 500, String(e)); - } - }, - }, - - // /api/threads/:id/reply - { - pattern: /^\/api\/threads\/([^/]+)\/reply$/, - method: "POST", - handler: async (req, res, ctx, m) => { - try { - const data = await parseBody(req); - const comment = ctx.session.addReply(m[1], data.body, data.author); - if (!comment) error(res, 404, "Thread not found"); - else json(res, comment); - } catch (e) { error(res, 400, String(e)); } - }, - }, - - // /api/threads/:id/status - { - pattern: /^\/api\/threads\/([^/]+)\/status$/, - method: "PATCH", - handler: async (req, res, ctx, m) => { - try { - const data = await parseBody(req); - const thread = ctx.session.updateThreadStatus(m[1], data.status); - if (!thread) error(res, 404, "Thread not found"); - else json(res, thread); - } catch (e) { error(res, 400, String(e)); } - }, - }, - - // /api/threads/:id/edit - { - pattern: /^\/api\/threads\/([^/]+)\/edit$/, - method: "PATCH", - handler: async (req, res, ctx, m) => { - try { - const data = await parseBody(req); - const thread = ctx.session.editThread(m[1], data.body); - if (!thread) error(res, 404, "Thread not found"); - else json(res, thread); - } catch (e) { error(res, 400, String(e)); } - }, - }, - - // /api/threads/:id (GET or DELETE) - { - pattern: /^\/api\/threads\/([^/]+)$/, - method: "GET", - handler: (_req, res, ctx, m) => { - const thread = ctx.session.getThread(m[1]); - if (!thread) error(res, 404, "Thread not found"); - else json(res, thread); - }, - }, - { - pattern: /^\/api\/threads\/([^/]+)$/, - method: "DELETE", - handler: (_req, res, ctx, m) => { - const ok = ctx.session.deleteThread(m[1]); - if (!ok) error(res, 404, "Thread not found"); - else json(res, { ok: true }); - }, - }, -]; - -// ── Router ──────────────────────────────────────────────────── - -/** - * Route an API request. Returns true if handled, false if no route matched - * (caller should serve static HTML). - */ -export function routeRequest( - req: IncomingMessage, - res: ServerResponse, - ctx: RouteContext, -): boolean { - const p = new URL(req.url || "/", "http://localhost").pathname; - const method = req.method || "GET"; - - // CORS preflight - if (method === "OPTIONS") { - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }); - res.end(); - return true; - } - - // Static routes - const handlers = staticRoutes[p]; - if (handlers) { - const handler = handlers[method]; - if (handler) { - Promise.resolve(handler(req, res, ctx)).catch(e => { - if (!res.headersSent) error(res, 500, String(e)); - }); - return true; - } - } - - // Dynamic routes - for (const route of dynamicRoutes) { - if (route.method !== method) continue; - const m = p.match(route.pattern); - if (m) { - Promise.resolve(route.handler(req, res, ctx, m)).catch(e => { - if (!res.headersSent) error(res, 500, String(e)); - }); - return true; - } - } - - return false; -} diff --git a/aine/server.ts b/aine/server.ts deleted file mode 100644 index c95044d..0000000 --- a/aine/server.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { createServer } from "node:http"; -import { readFileSync, watch, type FSWatcher } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { DiffSession } from "./diff-session.js"; -import { registerSession, unregisterSession, type SessionInfo } from "./session-store.js"; -import { loadTheme } from "./theme.js"; -import { routeRequest, error, type RouteContext } from "./routes.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const PAGE_HTML = readFileSync(join(__dirname, "dist", "index.html"), "utf-8"); -const PAGE_HTML_BUF = Buffer.from(PAGE_HTML, "utf-8"); -const PAGE_ETAG = `"${Bun.hash(PAGE_HTML).toString(36)}"`; - -export interface ServerOptions { - port: number; - diffArgs: string[]; - description: string; - cwd: string; - unified?: boolean; - noOpen?: boolean; - watch?: boolean; - theme?: string; - expandUnchanged?: boolean; - ignoreWhitespace?: boolean; -} - -// ── Server lifecycle ────────────────────────────────────────── - -let activeServer: ReturnType | null = null; -let activeSession: DiffSession | null = null; -let activeSessionInfo: SessionInfo | null = null; -let activeWatcher: FSWatcher | null = null; - -export function stopServer() { - if (activeWatcher) { - try { activeWatcher.close(); } catch {} - activeWatcher = null; - } - activeSession?.destroy(); - activeSession = null; - if (activeSessionInfo) { - unregisterSession(activeSessionInfo.id); - activeSessionInfo = null; - } - if (activeServer) { - try { activeServer.close(); } catch {} - activeServer = null; - } -} - -function startWatcher(cwd: string, session: DiffSession) { - if (activeWatcher) { - try { activeWatcher.close(); } catch {} - } - let debounceTimer: ReturnType | null = null; - activeWatcher = watch(cwd, { recursive: true }, (_event, filename) => { - if (!filename) return; - if (filename.startsWith(".git/") || filename.startsWith(".git\\")) return; - if (filename.includes("node_modules/")) return; - if (filename.endsWith(".swp") || filename.endsWith("~")) return; - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => session.markStale(), 300); - }); -} - -async function killPort(port: number) { - try { - const proc = Bun.spawn(["lsof", "-ti:" + port], { stdout: "pipe", stderr: "ignore" }); - const text = await new Response(proc.stdout).text(); - for (const pid of text.trim().split("\n").filter(Boolean)) { - try { process.kill(+pid, "SIGKILL"); } catch {} - } - } catch {} -} - -// ── Start server ────────────────────────────────────────────── - -export async function startServer(opts: ServerOptions): Promise { - const { port, diffArgs, description, cwd, unified, noOpen } = opts; - stopServer(); - await killPort(port); - - const theme = loadTheme(opts.theme || "catppuccin-mocha", cwd); - const themeInit = { - name: theme.name, - shikiTheme: theme.shikiTheme, - addBorder: theme.ui.addBorder, - deleteBorder: theme.ui.deleteBorder, - }; - - const session = new DiffSession(diffArgs, cwd, themeInit); - activeSession = session; - - // Apply initial render options from config - const initOpts: Record = {}; - if (unified) initOpts.diffStyle = "unified"; - if (opts.expandUnchanged !== undefined) initOpts.expandUnchanged = opts.expandUnchanged; - if (opts.ignoreWhitespace !== undefined) initOpts.ignoreWhitespace = opts.ignoreWhitespace; - if (Object.keys(initOpts).length > 0) session.setOptions(initOpts); - - session.preloadAll().catch(() => {}); - if (opts.watch) startWatcher(cwd, session); - - const routeCtx: RouteContext = { session, themeUI: theme.ui, cwd, diffArgs, description }; - - return new Promise((resolve, reject) => { - const s = createServer(async (req, res) => { - try { - // Try API routes first - if (routeRequest(req, res, routeCtx)) return; - - // Static: serve the SPA - if (req.headers["if-none-match"] === PAGE_ETAG) { - res.writeHead(304); - res.end(); - return; - } - res.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "ETag": PAGE_ETAG, - "Cache-Control": "no-cache", - }); - res.end(PAGE_HTML_BUF); - } catch (e) { - if (!res.headersSent) error(res, 500, String(e)); - } - }); - - activeServer = s; - s.on("error", (e: NodeJS.ErrnoException) => - reject(e.code === "EADDRINUSE" ? new Error("Port " + port + " in use") : e), - ); - s.listen(port, "127.0.0.1", () => { - activeSessionInfo = registerSession({ - id: crypto.randomUUID(), - repo: cwd, - repoName: session.repoName, - port, - pid: process.pid, - startedAt: new Date().toISOString(), - diffArgs, - description, - }); - if (!noOpen) { - try { Bun.spawn(["open", `http://localhost:${port}`], { stdout: "ignore", stderr: "ignore" }); } catch {} - } - resolve(port); - }); - }); -} diff --git a/aine/session-cli.ts b/aine/session-cli.ts deleted file mode 100644 index c5e6e2b..0000000 --- a/aine/session-cli.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * `aine session` subcommand handler. - * - * All session commands communicate with a running aine instance via HTTP. - * This is the primary integration point for AI agents. - */ - -import { listSessions, findSessionByRepo, findSessionById, type SessionInfo } from "./session-store.js"; -import { getRepoRoot } from "./git.js"; - -// ── Help ────────────────────────────────────────────────────── - -function printSessionHelp() { - console.log(` -aine session - Control running Aine instances - -COMMANDS - list [--json] List running instances - context (--repo | ) Show current session state - navigate (--repo | ) --file [--hunk N | --line N] - reload (--repo | ) [-- ] - comment add (--repo | ) --file --line [--end-line ] - --summary "..." [--severity must-fix|suggestion|nit] [--author ] - comment reply (--repo | ) --summary "..." - [--author ] - comment list (--repo | ) [--file ] [--status open|resolved|dismissed] [--json] - comment resolve (--repo | ) - comment dismiss (--repo | ) - comment rm (--repo | ) - comment clear (--repo | ) [--file ] --yes -`.trim()); -} - -// ── Arg parsing helpers ─────────────────────────────────────── - -function resolveSession(args: string[]): { session: SessionInfo; remaining: string[] } | null { - const sessions = listSessions(); - - if (sessions.length === 1 && !args.includes("--repo")) { - return { session: sessions[0], remaining: args }; - } - - const repoIdx = args.indexOf("--repo"); - if (repoIdx !== -1 && repoIdx + 1 < args.length) { - const repoPath = args[repoIdx + 1]; - let resolved: string; - try { resolved = getRepoRoot(repoPath === "." ? process.cwd() : repoPath); } - catch { resolved = repoPath; } - const session = findSessionByRepo(resolved); - if (!session) { - console.error(`No active session for repo: ${resolved}\nRun 'aine session list' to see running instances.`); - return null; - } - return { session, remaining: [...args.slice(0, repoIdx), ...args.slice(repoIdx + 2)] }; - } - - if (args.length > 0 && !args[0].startsWith("-")) { - const session = findSessionById(args[0]); - if (session) return { session, remaining: args.slice(1) }; - } - - console.error("No session specified. Use --repo or pass a session ID.\nRun 'aine session list' to see running instances."); - return null; -} - -function parseNamedArgs(args: string[]): { named: Record; positional: string[]; afterDash: string[] } { - const named: Record = {}; - const positional: string[] = []; - const afterDash: string[] = []; - let inAfterDash = false; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--" && !inAfterDash) { inAfterDash = true; continue; } - if (inAfterDash) { afterDash.push(args[i]); continue; } - if (args[i] === "--yes" || args[i] === "--json") { - named[args[i].slice(2)] = "true"; - } else if (args[i].startsWith("--") && i + 1 < args.length && !args[i + 1].startsWith("--")) { - named[args[i].slice(2)] = args[i + 1]; - i++; - } else { - positional.push(args[i]); - } - } - return { named, positional, afterDash }; -} - -async function apiCall(port: number, method: string, path: string, body?: unknown): Promise { - const url = `http://127.0.0.1:${port}${path}`; - const opts: RequestInit = { - method, - headers: body ? { "Content-Type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, - }; - const res = await fetch(url, opts); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error((data as any).error || `HTTP ${res.status}`); - } - return res.json(); -} - -/** - * Common pattern: resolve session from args at given offset, - * parse named args, call handler. - */ -function withSession( - args: string[], - offset: number, - handler: (api: (m: string, p: string, b?: unknown) => Promise, named: Record, positional: string[], afterDash: string[]) => Promise, -) { - const resolved = resolveSession(args.slice(offset)); - if (!resolved) process.exit(1); - const { named, positional, afterDash } = parseNamedArgs(resolved.remaining); - const api = (m: string, p: string, b?: unknown) => apiCall(resolved.session.port, m, p, b); - return handler(api, named, positional, afterDash); -} - -function requireArg(named: Record, ...keys: string[]): void { - const missing = keys.filter(k => !named[k]); - if (missing.length > 0) { - console.error(`Error: ${missing.map(k => `--${k}`).join(", ")} required`); - process.exit(1); - } -} - -function requirePositional(positional: string[], label: string): string { - if (!positional[0]) { console.error(`Error: ${label} required`); process.exit(1); } - return positional[0]; -} - -function makeAuthor(name?: string) { - const authorName = name || "agent"; - return { name: authorName, type: authorName === "user" ? "user" as const : "agent" as const }; -} - -// ── Subcommands ─────────────────────────────────────────────── - -export async function sessionCommand(args: string[]) { - if (args.length === 0 || args[0] === "help" || args[0] === "--help") { - printSessionHelp(); - return; - } - - const sub = args[0]; - - if (sub === "list") { - const sessions = listSessions(); - if (args.includes("--json")) { - console.log(JSON.stringify(sessions, null, 2)); - } else if (sessions.length === 0) { - console.log("No active Aine sessions."); - } else { - for (const s of sessions) - console.log(` ${s.repoName} → http://127.0.0.1:${s.port} (pid: ${s.pid}, ${s.description})`); - } - return; - } - - if (sub === "context") { - return withSession(args, 1, async (api, named) => { - const data = await api("GET", "/api/session/context"); - if (named.json === "true" || args.includes("--json")) { - console.log(JSON.stringify(data, null, 2)); - } else { - console.log(`Repo: ${data.repoName} (${data.branch})`); - console.log(`Diff: ${data.description}`); - console.log(`Files: ${data.files.length}`); - if (data.selectedFile) console.log(`Selected: ${data.selectedFile}`); - if (data.threadCount > 0) console.log(`Threads: ${data.threadCount}`); - console.log(`View: ${data.renderOptions.diffStyle}\n`); - for (const f of data.files) { - const stat = f.status === "added" ? "+" : f.status === "deleted" ? "-" : "~"; - const adds = f.additions > 0 ? ` +${f.additions}` : ""; - const dels = f.deletions > 0 ? ` -${f.deletions}` : ""; - console.log(` ${stat} ${f.path}${adds}${dels}`); - } - } - }); - } - - if (sub === "navigate") { - return withSession(args, 1, async (api, named) => { - requireArg(named, "file"); - await api("POST", "/api/session/navigate", { - file: named.file, - hunk: named.hunk ? parseInt(named.hunk) : undefined, - line: named.line ? parseInt(named.line) : undefined, - }); - console.log(`Navigated to ${named.file}${named.line ? `:${named.line}` : ""}${named.hunk ? ` hunk ${named.hunk}` : ""}`); - }); - } - - if (sub === "reload") { - return withSession(args, 1, async (api, _named, _pos, afterDash) => { - await api("POST", "/api/session/reload", { - diffArgs: afterDash, - description: afterDash.join(" ") || "Reloaded", - }); - console.log("Session reloaded"); - }); - } - - // ── comment subcommands ───────────────────────────────────── - - if (sub === "comment") { - const commentSub = args[1]; - if (!commentSub || commentSub === "help") { printSessionHelp(); return; } - - if (commentSub === "add") { - return withSession(args, 2, async (api, named) => { - requireArg(named, "file", "line", "summary"); - const thread = await api("POST", "/api/threads", { - file: named.file, - startLine: parseInt(named.line), - endLine: named["end-line"] ? parseInt(named["end-line"]) : parseInt(named.line), - body: named.summary, - severity: named.severity, - author: makeAuthor(named.author), - }); - const range = thread.startLine === thread.endLine ? `${thread.startLine}` : `${thread.startLine}-${thread.endLine}`; - console.log(`Thread ${thread.id} created on ${named.file}:${range}`); - }); - } - - if (commentSub === "reply") { - return withSession(args, 2, async (api, named, positional) => { - const id = requirePositional(positional, "thread ID"); - requireArg(named, "summary"); - const comment = await api("POST", `/api/threads/${id}/reply`, { - body: named.summary, - author: makeAuthor(named.author), - }); - console.log(`Reply ${comment.id} added to thread ${id}`); - }); - } - - if (commentSub === "list") { - return withSession(args, 2, async (api, named) => { - const params = new URLSearchParams(); - if (named.file) params.set("file", named.file); - if (named.status) params.set("status", named.status); - const query = params.toString() ? `?${params}` : ""; - const threads = await api("GET", `/api/threads${query}`); - if (named.json === "true" || args.includes("--json")) { - console.log(JSON.stringify(threads, null, 2)); - } else if (threads.length === 0) { - console.log("No threads."); - } else { - for (const t of threads) { - const first = t.comments[0]; - const severity = first?.severity ? ` [${first.severity}]` : ""; - const statusIcon = t.status === "resolved" ? " ✓" : t.status === "dismissed" ? " ✗" : ""; - const range = t.startLine === t.endLine ? `${t.startLine}` : `${t.startLine}-${t.endLine}`; - const replies = t.comments.length > 1 ? ` (${t.comments.length} comments)` : ""; - const body = first?.body || ""; - const truncated = body.length > 60 ? body.slice(0, 57) + "..." : body; - console.log(` ${t.id} ${t.file}:${range}${severity}${statusIcon}${replies} — ${truncated}`); - } - } - }); - } - - if (commentSub === "resolve" || commentSub === "dismiss") { - return withSession(args, 2, async (api, _named, positional) => { - const id = requirePositional(positional, "thread ID"); - const status = commentSub === "resolve" ? "resolved" : "dismissed"; - await api("PATCH", `/api/threads/${id}/status`, { status }); - console.log(`Thread ${id} ${commentSub}${commentSub.endsWith("e") ? "d" : "ed"}`); - }); - } - - if (commentSub === "rm") { - return withSession(args, 2, async (api, _named, positional) => { - const id = requirePositional(positional, "thread ID"); - await api("DELETE", `/api/threads/${id}`); - console.log(`Thread ${id} deleted`); - }); - } - - if (commentSub === "clear") { - return withSession(args, 2, async (api, named) => { - if (named.yes !== "true") { console.error("Error: --yes required to confirm"); process.exit(1); } - const params = new URLSearchParams(); - if (named.file) params.set("file", named.file); - const query = params.toString() ? `?${params}` : ""; - const threads = await api("GET", `/api/threads${query}`); - for (const t of threads) await api("DELETE", `/api/threads/${t.id}`); - console.log(`Cleared ${threads.length} thread(s)`); - }); - } - - console.error(`Unknown comment subcommand: ${commentSub}`); - process.exit(1); - } - - console.error(`Unknown session command: ${sub}`); - printSessionHelp(); - process.exit(1); -} diff --git a/aine/session-store.ts b/aine/session-store.ts deleted file mode 100644 index caca9bf..0000000 --- a/aine/session-store.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * File-based session store for Aine instances. - * - * Each running aine server writes a JSON file to ~/.config/aine/sessions/.json. - * `aine session list` reads all files. Stale sessions (dead PIDs) are cleaned up. - */ - -import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; - -export interface SessionInfo { - id: string; - repo: string; - repoName: string; - port: number; - pid: number; - startedAt: string; - diffArgs: string[]; - description: string; -} - -const SESSIONS_DIR = join(homedir(), ".config", "aine", "sessions"); - -function ensureDir() { - mkdirSync(SESSIONS_DIR, { recursive: true }); -} - -function sessionPath(id: string): string { - return join(SESSIONS_DIR, `${id}.json`); -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -export function registerSession(info: SessionInfo): SessionInfo { - ensureDir(); - writeFileSync(sessionPath(info.id), JSON.stringify(info, null, 2)); - - // Auto-cleanup on exit - const cleanup = () => { - try { unlinkSync(sessionPath(info.id)); } catch {} - }; - process.on("exit", cleanup); - process.on("SIGINT", () => { cleanup(); process.exit(0); }); - process.on("SIGTERM", () => { cleanup(); process.exit(0); }); - - return info; -} - -export function unregisterSession(id: string) { - try { unlinkSync(sessionPath(id)); } catch {} -} - -export function listSessions(): SessionInfo[] { - ensureDir(); - const sessions: SessionInfo[] = []; - const stale: string[] = []; - - for (const file of readdirSync(SESSIONS_DIR)) { - if (!file.endsWith(".json")) continue; - try { - const info: SessionInfo = JSON.parse(readFileSync(join(SESSIONS_DIR, file), "utf-8")); - if (isProcessAlive(info.pid)) { - sessions.push(info); - } else { - stale.push(file); - } - } catch { - stale.push(file); - } - } - - // Clean up stale session files - for (const file of stale) { - try { unlinkSync(join(SESSIONS_DIR, file)); } catch {} - } - - return sessions; -} - -/** Find a session by repo path (resolved to absolute). */ -export function findSessionByRepo(repoPath: string): SessionInfo | null { - const resolved = repoPath.startsWith("/") ? repoPath : join(process.cwd(), repoPath); - const sessions = listSessions(); - return sessions.find(s => s.repo === resolved) ?? null; -} - -/** Find a session by ID. */ -export function findSessionById(id: string): SessionInfo | null { - const p = sessionPath(id); - if (!existsSync(p)) return null; - try { - const info: SessionInfo = JSON.parse(readFileSync(p, "utf-8")); - return isProcessAlive(info.pid) ? info : null; - } catch { - return null; - } -} diff --git a/aine/skills/aine-review/SKILL.md b/aine/skills/aine-review/SKILL.md deleted file mode 100644 index f751f8a..0000000 --- a/aine/skills/aine-review/SKILL.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -name: aine-review -description: Interacts with live Aine diff review sessions via CLI. Inspects review context, navigates files, reloads sessions, and creates threaded inline review comments with severity levels and replies. Use when the user has an Aine session running or wants to review diffs interactively. ---- - -# Aine Review - -Aine is an agent-agnostic diff viewer and code review tool. The browser UI is for the user — do NOT run `aine`, `aine diff`, or `aine show` directly. Use `aine session *` CLI commands to inspect and control live sessions. - -If no session exists, ask the user to launch Aine in their terminal first: -```bash -aine # working tree changes -aine diff main # diff against main -aine show HEAD~1 # last commit -``` - -## Workflow - -``` -1. aine session list # find live sessions -2. aine session context --repo . # check what's being reviewed -3. aine session navigate ... # move to the right place -4. aine session reload -- ... # swap contents if needed -5. aine session comment add ... # leave review notes -6. aine session comment reply ... # reply to existing threads -``` - -## Session selection - -Every command (except `list`) needs a session target: - -- `--repo ` — match by repo root (most common, use `--repo .`) -- `` — match by exact session ID (when multiple sessions share a repo) -- If only one session exists, it auto-resolves - -## Commands - -### Inspect - -```bash -aine session list [--json] -aine session context (--repo . | ) [--json] -``` - -### Navigate - -```bash -aine session navigate --repo . --file src/App.tsx --hunk 2 -aine session navigate --repo . --file src/App.tsx --line 372 -``` - -- `--hunk ` is 1-based -- `--line ` is 1-based (new file side) - -### Reload - -Swaps what a live session is showing without opening a new browser tab: - -```bash -aine session reload --repo . -- diff -aine session reload --repo . -- diff --staged -aine session reload --repo . -- show HEAD~1 -``` - -### Threads (comments) - -Aine uses a **thread model** for comments. Each thread has a file location (with optional line range), a status, and a chain of comments (original + replies). - -```bash -# Create a new thread (starts a conversation on a line or line range) -aine session comment add --repo . --file README.md --line 103 --summary "Tighten this wording" [--end-line 110] [--severity suggestion] [--author agent] - -# Reply to an existing thread -aine session comment reply --repo . --summary "Good point, will fix" [--author agent] - -# List threads -aine session comment list --repo . [--file README.md] [--status open] [--json] - -# Change thread status -aine session comment resolve --repo . -aine session comment dismiss --repo . - -# Delete a thread -aine session comment rm --repo . - -# Clear all threads -aine session comment clear --repo . --yes [--file README.md] -``` - -- `comment add` requires `--file`, `--line`, and `--summary` -- `--end-line` enables multi-line thread (default: same as `--line`) -- `--severity` is one of: `must-fix`, `suggestion`, `nit` (default: none) -- `--author` defaults to `agent` -- `--status` filter for list: `open`, `resolved`, `dismissed` -- Thread IDs support short prefix lookup (min 4 chars) -- Quote `--summary` defensively in the shell - -## Thread statuses - -| Status | Meaning | -|--------|---------| -| `open` | Active, needs attention | -| `resolved` | Fixed or addressed | -| `dismissed` | Not applicable, won't fix | - -## Severity levels - -| Level | When to use | -|-------|-------------| -| `must-fix` | Bugs, security issues, logic errors — must be addressed | -| `suggestion` | Improvements, better patterns — worth considering | -| `nit` | Style, naming, minor preferences — optional | - -## Guiding a review - -When asked to review code, your role is to narrate and leave actionable comments. - -Typical flow: - -1. `aine session context --repo .` — understand the changeset -2. Navigate to the first interesting file -3. Add a thread explaining what's wrong and why -4. Move to the next point of interest — repeat -5. Summarize findings when done - -Guidelines: - -- Work in the order that tells the clearest story, not file order -- Navigate before commenting so the user sees the relevant code -- Keep comments focused: bugs, security, performance, clarity -- Use severity levels — don't mark everything as `must-fix` -- Use `--end-line` when the issue spans multiple lines -- Don't comment on every line — highlight what matters -- Use `reply` to respond to user questions on existing threads -- After review, suggest the user run resolve to apply fixes - -## Common errors - -- **"No active Aine sessions"** — ask the user to open Aine in their terminal -- **"No active session for repo"** — wrong directory, check with `aine session list` -- **"Thread not found"** — wrong ID, check with `aine session comment list --json` diff --git a/aine/src/App.svelte b/aine/src/App.svelte deleted file mode 100644 index dadae1c..0000000 --- a/aine/src/App.svelte +++ /dev/null @@ -1,251 +0,0 @@ - - - - -
- - {#if stale} - - {/if} -
- - -
-
- - - - - diff --git a/aine/src/components/CommentInput.svelte b/aine/src/components/CommentInput.svelte deleted file mode 100644 index ce0ce52..0000000 --- a/aine/src/components/CommentInput.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - -
e.stopPropagation()}> -
- - {#if isEdit && isRange}Editing · Lines {startLine}–{endLine} - {:else if isEdit}Editing · Line {startLine} - {:else if isRange}Lines {startLine}–{endLine} - {:else}Line {startLine}{/if} - - {#if !isEdit && !isRange}shift+click for range{/if} -
- - -
- - diff --git a/aine/src/components/CommentsPanel.svelte b/aine/src/components/CommentsPanel.svelte deleted file mode 100644 index 7c1f3a1..0000000 --- a/aine/src/components/CommentsPanel.svelte +++ /dev/null @@ -1,444 +0,0 @@ - - -
-
- Comments - {#if unresolvedCount > 0} - {unresolvedCount} - {/if} -
- -
- {#if $fileThreads.length === 0} -
- Click a line number to comment -
- {:else} -
- {#each [...openThreads, ...resolvedThreads] as thread (thread.id)} - {@const comment = thread.comments[0]} - {@const author = comment?.author ?? { name: "user", type: "user" }} - {@const avatarColor = getAvatarColor(author)} - {@const accent = getAccentColor(author)} - {@const isFocused = focusedThreadId === thread.id} - {@const isResolved = thread.status === "resolved"} - {@const top = cardPositions.get(thread.id) ?? 0} - - -
handleCardEnter(thread)} - onmouseleave={handleCardLeave} - onclick={() => handleCardClick(thread)} - use:trackCard={thread.id} - > -
- - {author.name} - {#if isResolved} - ✓ - {/if} - {relativeTime(thread.createdAt)} - -
-
{comment?.body ?? ""}
-
- {#if !isResolved} - - {/if} - - -
-
- {/each} -
- {/if} -
-
- - diff --git a/aine/src/components/DiffView.svelte b/aine/src/components/DiffView.svelte deleted file mode 100644 index 2a521e0..0000000 --- a/aine/src/components/DiffView.svelte +++ /dev/null @@ -1,682 +0,0 @@ - - - - - -
-
-
{hunkBadgeText}
- - {#if showSpinner} -
Loading…
- {:else if errorMsg} -
{errorMsg}
- {:else if !currentHtml && !$selectedPath} -
Select a file
- {/if} - -
- - {#if commentStartLine !== null && $selectedPath && commentInputTop !== null} - {@const range = commentLineRange()} - {#if range} - - {/if} - {/if} -
- - diff --git a/aine/src/components/HelpOverlay.svelte b/aine/src/components/HelpOverlay.svelte deleted file mode 100644 index 395036d..0000000 --- a/aine/src/components/HelpOverlay.svelte +++ /dev/null @@ -1,129 +0,0 @@ - - - - -
-
-

Keyboard Shortcuts

- -
-

Files

-
j / ↓Next file
-
k / ↑Previous file
-
JLast file
-
KFirst file
-
yCopy file path
-
bToggle sidebar
-
h / lCollapse / expand folder
-
H / LCollapse / expand all
-
- -
-

Diff navigation

-
nNext changed hunk
-
NPrevious changed hunk
-
ggScroll to top
-
GScroll to bottom
-
Ctrl+d / uScroll ½ page down / up
-
Ctrl+f / bScroll full page down / up
-
- -
-

Display

-
sToggle split / unified
-
wToggle ignore whitespace
-
eToggle expand unchanged
-
- -
-

Comments

-
cToggle inline comments
-
click gutterAdd comment on line
-
⌘EnterSubmit comment
-
EscCancel comment
-
- -
-

General

-
rRefresh diff (when files changed)
-
?Toggle this help
-
- -
Press ? or Esc to close
-
-
- - diff --git a/aine/src/components/Sidebar.svelte b/aine/src/components/Sidebar.svelte deleted file mode 100644 index 4813927..0000000 --- a/aine/src/components/Sidebar.svelte +++ /dev/null @@ -1,204 +0,0 @@ - - - - - diff --git a/aine/src/components/Toast.svelte b/aine/src/components/Toast.svelte deleted file mode 100644 index d6bb414..0000000 --- a/aine/src/components/Toast.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - -
- {$toastMessage ?? ""} -
- - diff --git a/aine/src/components/Toolbar.svelte b/aine/src/components/Toolbar.svelte deleted file mode 100644 index ccb853f..0000000 --- a/aine/src/components/Toolbar.svelte +++ /dev/null @@ -1,293 +0,0 @@ - - -
- -
- {$repoInfo?.name ?? ""} - {$repoInfo?.branch ?? ""} -
- - │ - - -
- -
- - │ - - -
- {$files.length} file{$files.length !== 1 ? "s" : ""}{#if $totalAdditions > 0}{" "}+{$totalAdditions}{/if}{#if $totalDeletions > 0}{" "}−{$totalDeletions}{/if} -
- - {#if $preloadStatus.total > 0} - │ - - - - {#if $preloadStatus.preloading} - - {:else} - ✓ - {/if} - cache {$preloadStatus.cached}/{$preloadStatus.total} - - {/if} - - │ - - - │ - - -
- - - -
- - │ - - -
- - -
-
- - diff --git a/aine/src/index.html b/aine/src/index.html deleted file mode 100644 index 88648f4..0000000 --- a/aine/src/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - aine - - -
- - - diff --git a/aine/src/lib/api.ts b/aine/src/lib/api.ts deleted file mode 100644 index 2c34e3a..0000000 --- a/aine/src/lib/api.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { ChangedFile, RepoInfo, RenderOptions, Thread, ThreadStatus, ThreadAuthor, Severity } from "./state"; - -export async function fetchTheme(): Promise> { - const r = await fetch("/api/theme"); - return r.json(); -} - -export async function fetchInfo(): Promise { - const r = await fetch("/api/info"); - return r.json(); -} - -export async function fetchFiles(): Promise { - const r = await fetch("/api/files"); - return r.json(); -} - -export async function fetchDiff( - path: string, - signal?: AbortSignal -): Promise<{ file: ChangedFile; html: string } | null> { - const r = await fetch("/api/diff/" + encodeURIComponent(path), { signal }); - if (!r.ok) { - const d = await r.json().catch(() => ({})); - throw new Error((d as any).error || "HTTP " + r.status); - } - return r.json(); -} - -export async function fetchOptions(): Promise { - const r = await fetch("/api/options"); - return r.json(); -} - -export async function updateOptions( - opts: Partial -): Promise { - const r = await fetch("/api/options", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(opts), - }); - return r.json(); -} - -export async function refresh(): Promise { - await fetch("/api/refresh"); -} - -export async function reloadSession(diffArgs: string[], description: string): Promise { - await fetch("/api/session/reload", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ diffArgs, description }), - }); -} - -// ── Threads API ─────────────────────────────────────────────── - -export async function fetchThreads(file?: string, status?: ThreadStatus): Promise { - const params = new URLSearchParams(); - if (file) params.set("file", file); - if (status) params.set("status", status); - const query = params.toString() ? `?${params}` : ""; - const r = await fetch(`/api/threads${query}`); - return r.json(); -} - -export async function createThread(data: { - file: string; - startLine: number; - endLine?: number; - side?: "old" | "new"; - body: string; - severity?: Severity; - author?: ThreadAuthor; -}): Promise { - const r = await fetch("/api/threads", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - return r.json(); -} - -export async function editThread(id: string, body: string): Promise { - const r = await fetch(`/api/threads/${id}/edit`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ body }), - }); - return r.json(); -} - -export async function updateThreadStatus(id: string, status: ThreadStatus): Promise { - const r = await fetch(`/api/threads/${id}/status`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status }), - }); - return r.json(); -} - -export async function deleteThread(id: string): Promise { - await fetch(`/api/threads/${id}`, { method: "DELETE" }); -} diff --git a/aine/src/lib/diff-highlights.ts b/aine/src/lib/diff-highlights.ts deleted file mode 100644 index a4e4874..0000000 --- a/aine/src/lib/diff-highlights.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Shadow DOM highlight operations for the diff view. - * Pure functions — no component state, just DOM manipulation. - */ - -import type { Thread } from "../../types.js"; - -/** Get the ShadowRoot from the diff output container. */ -export function getShadow(diffOutEl: HTMLElement | null): ShadowRoot | null { - const wrapper = diffOutEl?.firstChild as HTMLElement | null; - return wrapper?.shadowRoot ?? null; -} - -// ── Gutter selection highlight (comment line range) ─────────── - -export function updateGutterHighlights( - shadow: ShadowRoot, - range: { start: number; end: number } | null, -) { - shadow.querySelectorAll(".aine-gutter-sel").forEach(el => - el.classList.remove("aine-gutter-sel"), - ); - if (!range) return; - - for (const el of shadow.querySelectorAll("[data-line-number-content]")) { - const text = el.textContent?.trim(); - if (!text || !/^\d+$/.test(text)) continue; - const n = parseInt(text); - if (n >= range.start && n <= range.end) { - (el as HTMLElement).classList.add("aine-gutter-sel"); - } - } -} - -// ── Commented lines: gutter stripe per thread ───────────────── - -export function highlightCommentedLines( - shadow: ShadowRoot, - threads: Thread[], -) { - // Clear old - for (const el of shadow.querySelectorAll("[data-aine-comment-line]")) { - (el as HTMLElement).style.boxShadow = ""; - (el as HTMLElement).style.background = ""; - el.removeAttribute("data-aine-comment-line"); - } - - // Build line → color map (agent=blue, user=purple) - const lineColors = new Map(); - for (const thread of threads) { - const isAgent = thread.comments[0]?.author.type === "agent"; - const color = isAgent ? "#89b4fa" : "#cba6f7"; - for (let ln = thread.startLine; ln <= thread.endLine; ln++) { - if (!lineColors.has(ln) || isAgent) lineColors.set(ln, color); - } - } - if (lineColors.size === 0) return; - - // Gutter cells: left stripe - for (const el of shadow.querySelectorAll("[data-line-type][data-column-number]")) { - const ln = parseInt(el.getAttribute("data-column-number") || "0"); - const color = lineColors.get(ln); - if (!color) continue; - (el as HTMLElement).style.boxShadow = `inset 3px 0 0 0 ${color}`; - el.setAttribute("data-aine-comment-line", ""); - } - - // Kill hunk separator box-shadow on commented + adjacent lines - const killShadow = new Set(lineColors.keys()); - for (const thread of threads) { - killShadow.add(thread.startLine - 1); - killShadow.add(thread.endLine + 1); - } - - for (const el of shadow.querySelectorAll("[data-content] > [data-line]")) { - const ln = parseInt(el.getAttribute("data-line") || "0"); - if (!killShadow.has(ln)) continue; - (el as HTMLElement).style.boxShadow = "none"; - el.setAttribute("data-aine-comment-line", ""); - } - - for (const el of shadow.querySelectorAll("[data-line-type][data-column-number]")) { - const ln = parseInt(el.getAttribute("data-column-number") || "0"); - if (lineColors.has(ln)) continue; // already styled with stripe - if (!killShadow.has(ln)) continue; - (el as HTMLElement).style.boxShadow = "none"; - el.setAttribute("data-aine-comment-line", ""); - } -} - -// ── Hover highlight (from CommentsPanel card hover) ─────────── - -export function applyHoverHighlight( - shadow: ShadowRoot, - diffViewEl: HTMLElement, - range: { start: number; end: number } | null, - threads: Thread[], -) { - // Clear previous - for (const el of shadow.querySelectorAll("[data-aine-hover-hl]")) { - (el as HTMLElement).style.background = ""; - (el as HTMLElement).style.outline = ""; - (el as HTMLElement).style.outlineOffset = ""; - (el as HTMLElement).style.boxShadow = ""; - el.removeAttribute("data-aine-hover-hl"); - } - - if (!range) { - highlightCommentedLines(shadow, threads); - return; - } - - const { start, end } = range; - const hoverBg = "rgba(203, 166, 247, 0.15)"; - const hoverStripe = "inset 3px 0 0 0 #cba6f7"; - - for (const el of shadow.querySelectorAll("[data-line-type][data-column-number]")) { - const ln = parseInt(el.getAttribute("data-column-number") || "0"); - if (ln >= start && ln <= end) { - const s = (el as HTMLElement).style; - s.background = hoverBg; - s.boxShadow = hoverStripe; - el.setAttribute("data-aine-hover-hl", ""); - } - } - - for (const el of shadow.querySelectorAll("[data-content] > [data-line]")) { - const ln = parseInt(el.getAttribute("data-line") || "0"); - if (ln >= start && ln <= end) { - const s = (el as HTMLElement).style; - s.background = hoverBg; - s.boxShadow = "none"; - el.setAttribute("data-aine-hover-hl", ""); - } - } - - // Scroll first highlighted line into view - const firstEl = shadow.querySelector(`[data-content] > [data-line="${start}"]`); - if (firstEl && diffViewEl) { - const viewRect = diffViewEl.getBoundingClientRect(); - const elRect = firstEl.getBoundingClientRect(); - if (elRect.top < viewRect.top || elRect.bottom > viewRect.bottom) { - firstEl.scrollIntoView({ block: "center", behavior: "smooth" }); - } - } -} - -// ── Line position helpers ───────────────────────────────────── - -/** Measure bottom Y of a line (relative to scroll container). */ -export function measureLineBottom( - shadow: ShadowRoot, - diffViewEl: HTMLElement, - lineNumber: number, -): number | null { - for (const el of shadow.querySelectorAll("[data-column-number]")) { - if (parseInt(el.getAttribute("data-column-number") || "0") === lineNumber) { - const viewRect = diffViewEl.getBoundingClientRect(); - const rect = el.getBoundingClientRect(); - return rect.bottom - viewRect.top + diffViewEl.scrollTop; - } - } - return null; -} - -/** Get top Y of a line (for CommentsPanel card alignment). */ -export function getLineTopInScroll( - shadow: ShadowRoot, - diffViewEl: HTMLElement, - lineNumber: number, -): number | null { - const el = shadow.querySelector(`[data-content] > [data-line="${lineNumber}"]`); - if (!el) return null; - const viewRect = diffViewEl.getBoundingClientRect(); - const elRect = el.getBoundingClientRect(); - return elRect.top - viewRect.top + diffViewEl.scrollTop; -} diff --git a/aine/src/lib/events.ts b/aine/src/lib/events.ts deleted file mode 100644 index d33a2db..0000000 --- a/aine/src/lib/events.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * SSE event stream management. - * Returns cleanup functions for use in onMount. - */ - -export function createPreloadStream( - onStatus: (status: { cached: number; total: number; preloading: boolean }) => void, -): () => void { - const es = new EventSource("/api/preload-stream"); - es.onmessage = (e) => { - try { onStatus(JSON.parse(e.data)); } catch {} - }; - es.onerror = () => { es.close(); }; - return () => es.close(); -} - -export interface EventStreamCallbacks { - onNavigate: (file: string) => void; - onStale: () => void; - onRefresh: () => void; -} - -export function createEventStream(callbacks: EventStreamCallbacks): () => void { - let es: EventSource | null = null; - let reconnectTimer: ReturnType | null = null; - - function connect() { - es = new EventSource("/api/events"); - es.onmessage = async (e) => { - try { - const event = JSON.parse(e.data); - if (event.type === "navigate" && event.file) callbacks.onNavigate(event.file); - if (event.type === "stale") callbacks.onStale(); - if (event.type === "reload" || event.type === "refresh") callbacks.onRefresh(); - } catch {} - }; - es.onerror = () => { - es?.close(); - es = null; - reconnectTimer = setTimeout(connect, 2000); - }; - } - - connect(); - - return () => { - if (reconnectTimer) clearTimeout(reconnectTimer); - es?.close(); - }; -} diff --git a/aine/src/lib/file-tree.ts b/aine/src/lib/file-tree.ts deleted file mode 100644 index 3d977ca..0000000 --- a/aine/src/lib/file-tree.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * File tree builder and navigation helpers for the sidebar. - * Pure functions — no UI state, just data transformation. - */ - -import type { ChangedFile } from "../../types.js"; - -// ── Types ───────────────────────────────────────────────────── - -export interface TreeNode { - name: string; - kids: Record; - items: { file: ChangedFile; base: string }[]; -} - -export interface SidebarItem { - type: "file" | "folder"; - key: string; -} - -// ── Tree building ───────────────────────────────────────────── - -/** Build a tree from a flat file list, grouping by directory segments. */ -export function buildTree(list: ChangedFile[]): TreeNode { - const root: TreeNode = { name: "", kids: {}, items: [] }; - for (const f of list) { - const parts = f.path.split("/"); - let node = root; - for (let j = 0; j < parts.length - 1; j++) { - if (!node.kids[parts[j]]) - node.kids[parts[j]] = { name: parts[j], kids: {}, items: [] }; - node = node.kids[parts[j]]; - } - node.items.push({ file: f, base: parts[parts.length - 1] }); - } - squash(root); - return root; -} - -/** Collapse single-child directories (e.g. "src/lib" instead of "src" → "lib"). */ -function squash(n: TreeNode) { - for (const k of Object.keys(n.kids)) squash(n.kids[k]); - const keys = Object.keys(n.kids); - if (keys.length === 1 && n.items.length === 0) { - const c = n.kids[keys[0]]; - n.name = n.name ? n.name + "/" + c.name : c.name; - n.kids = c.kids; - n.items = c.items; - } -} - -// ── Item collection ─────────────────────────────────────────── - -/** - * Collect visible sidebar items (folders + files) in DFS visual order. - * Collapsed folders are included but their children are skipped. - */ -export function collectVisibleItems( - node: TreeNode, - prefix: string, - isFolderOpen: (key: string) => boolean, -): SidebarItem[] { - const items: SidebarItem[] = []; - for (const k of Object.keys(node.kids).sort()) { - const child = node.kids[k]; - const folderKey = prefix + "/" + child.name; - items.push({ type: "folder", key: folderKey }); - if (isFolderOpen(folderKey)) { - items.push(...collectVisibleItems(child, folderKey, isFolderOpen)); - } - } - for (const item of node.items.slice().sort((a, b) => a.base.localeCompare(b.base))) { - items.push({ type: "file", key: item.file.path }); - } - return items; -} - -/** Collect all folder keys from the tree (for collapse/expand all). */ -export function collectAllFolderKeys(node: TreeNode, prefix: string): string[] { - const keys: string[] = []; - for (const k of Object.keys(node.kids).sort()) { - const child = node.kids[k]; - const folderKey = prefix + "/" + child.name; - keys.push(folderKey); - keys.push(...collectAllFolderKeys(child, folderKey)); - } - return keys; -} - -// ── Folder lookup ───────────────────────────────────────────── - -/** Find the innermost folder containing the given file path. */ -export function findInnermostFolder(node: TreeNode, prefix: string, path: string): string | null { - for (const k of Object.keys(node.kids).sort()) { - const child = node.kids[k]; - const folderKey = prefix + "/" + child.name; - if (path.startsWith(folderKey + "/")) { - const deeper = findInnermostFolder(child, folderKey, path); - return deeper ?? folderKey; - } - } - return null; -} - -/** Find the outermost collapsed folder containing the given file path. */ -export function findOutermostCollapsedFolder( - node: TreeNode, - prefix: string, - path: string, - isFolderOpen: (key: string) => boolean, -): string | null { - for (const k of Object.keys(node.kids).sort()) { - const child = node.kids[k]; - const folderKey = prefix + "/" + child.name; - if (path.startsWith(folderKey + "/")) { - if (!isFolderOpen(folderKey)) return folderKey; - return findOutermostCollapsedFolder(child, folderKey, path, isFolderOpen); - } - } - return null; -} - -// ── Display helpers ─────────────────────────────────────────── - -export function statusGlyph(status: string): string { - switch (status) { - case "added": return "+"; - case "deleted": return "−"; - case "modified": return "·"; - case "renamed": return "→"; - case "copied": return "©"; - default: return "?"; - } -} diff --git a/aine/src/lib/keyboard.ts b/aine/src/lib/keyboard.ts deleted file mode 100644 index 23e2b50..0000000 --- a/aine/src/lib/keyboard.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Keyboard handler - dispatches actions based on key presses. - * Returns an action descriptor so the caller (App.svelte) can handle side effects. - */ - -export type Action = - | { type: "nextFile" } - | { type: "prevFile" } - | { type: "firstFile" } - | { type: "lastFile" } - | { type: "copyPath" } - | { type: "toggleSidebar" } - | { type: "collapseFolder" } - | { type: "expandFolder" } - | { type: "collapseAll" } - | { type: "expandAll" } - | { type: "nextHunk" } - | { type: "prevHunk" } - | { type: "scrollTop" } - | { type: "scrollBottom" } - | { type: "scrollHalfDown" } - | { type: "scrollHalfUp" } - | { type: "scrollPageDown" } - | { type: "scrollPageUp" } - | { type: "toggleStyle" } - | { type: "toggleWhitespace" } - | { type: "toggleExpand" } - | { type: "toggleHelp" } - | { type: "toggleComments" } - | { type: "refresh" } - | null; - -let pendingG = false; - -export function handleKeydown(e: KeyboardEvent): Action { - if ( - (e.target as HTMLElement).tagName === "INPUT" || - (e.target as HTMLElement).tagName === "TEXTAREA" - ) - return null; - - if (e.key === "?") { - e.preventDefault(); - return { type: "toggleHelp" }; - } - - if (e.key === "Escape") { - e.preventDefault(); - return { type: "toggleHelp" }; // App will check if help is visible - } - - if (e.ctrlKey) { - switch (e.key) { - case "d": e.preventDefault(); return { type: "scrollHalfDown" }; - case "u": e.preventDefault(); return { type: "scrollHalfUp" }; - case "f": e.preventDefault(); return { type: "scrollPageDown" }; - case "b": e.preventDefault(); return { type: "scrollPageUp" }; - } - return null; - } - - // gg sequence - if (e.key === "g" && !e.ctrlKey && !e.metaKey && !e.shiftKey) { - if (pendingG) { - e.preventDefault(); - pendingG = false; - return { type: "scrollTop" }; - } - pendingG = true; - setTimeout(() => { pendingG = false; }, 500); - return null; - } - pendingG = false; - - switch (e.key) { - case "G": e.preventDefault(); return { type: "scrollBottom" }; - case "n": e.preventDefault(); return { type: "nextHunk" }; - case "N": e.preventDefault(); return { type: "prevHunk" }; - case "s": e.preventDefault(); return { type: "toggleStyle" }; - case "w": e.preventDefault(); return { type: "toggleWhitespace" }; - case "e": e.preventDefault(); return { type: "toggleExpand" }; - case "y": e.preventDefault(); return { type: "copyPath" }; - case "b": e.preventDefault(); return { type: "toggleSidebar" }; - case "h": e.preventDefault(); return { type: "collapseFolder" }; - case "l": e.preventDefault(); return { type: "expandFolder" }; - case "H": e.preventDefault(); return { type: "collapseAll" }; - case "L": e.preventDefault(); return { type: "expandAll" }; - case "c": e.preventDefault(); return { type: "toggleComments" }; - case "r": e.preventDefault(); return { type: "refresh" }; - case "j": case "ArrowDown": e.preventDefault(); return { type: "nextFile" }; - case "k": case "ArrowUp": e.preventDefault(); return { type: "prevFile" }; - case "K": e.preventDefault(); return { type: "firstFile" }; - case "J": e.preventDefault(); return { type: "lastFile" }; - } - - return null; -} diff --git a/aine/src/lib/state.ts b/aine/src/lib/state.ts deleted file mode 100644 index 22cf04b..0000000 --- a/aine/src/lib/state.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { writable, derived } from "svelte/store"; -import type { - ChangedFile, - RepoInfo, - RenderOptions, - Thread, -} from "../../types.js"; - -export type { - ChangedFile, - RepoInfo, - RenderOptions, - Severity, - ThreadStatus, - ThreadAuthor, - ThreadComment, - Thread, -} from "../../types.js"; - -export const files = writable([]); -export const repoInfo = writable(null); -export const selectedPath = writable(null); -export const renderOptions = writable({ - diffStyle: "split", - expandUnchanged: true, - ignoreWhitespace: false, -}); -export const helpVisible = writable(false); -export const sidebarVisible = writable(true); -export const toastMessage = writable(null); -export const preloadStatus = writable<{ cached: number; total: number; preloading: boolean }>({ - cached: 0, - total: 0, - preloading: false, -}); -export const threads = writable([]); -export const commentsPanelVisible = writable(true); - -export const totalAdditions = derived(files, ($files) => - $files.reduce((sum, f) => sum + f.additions, 0) -); -export const totalDeletions = derived(files, ($files) => - $files.reduce((sum, f) => sum + f.deletions, 0) -); - -/** Threads for the currently selected file. */ -export const fileThreads = derived( - [threads, selectedPath], - ([$threads, $selectedPath]) => { - if (!$selectedPath) return []; - return $threads.filter(t => t.file === $selectedPath); - } -); diff --git a/aine/src/lib/theme-apply.ts b/aine/src/lib/theme-apply.ts deleted file mode 100644 index e70a053..0000000 --- a/aine/src/lib/theme-apply.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Apply theme colors to CSS custom properties. - */ -export function applyTheme(t: Record) { - const root = document.documentElement; - root.style.setProperty("--bg", t.bg); - root.style.setProperty("--sidebar", t.sidebar); - root.style.setProperty("--hover", t.hover); - root.style.setProperty("--active", t.active); - root.style.setProperty("--text", t.text); - root.style.setProperty("--muted", t.muted); - root.style.setProperty("--accent", t.accent); - root.style.setProperty("--border", t.border); - root.style.setProperty("--add", t.add); - root.style.setProperty("--del", t.delete); - root.style.setProperty("--mod", t.modified); - root.style.setProperty("--diffs-bg-addition-override", t.addBg); - root.style.setProperty("--diffs-bg-addition-number-override", t.addBg); - root.style.setProperty("--diffs-bg-deletion-override", t.deleteBg); - root.style.setProperty("--diffs-bg-deletion-number-override", t.deleteBg); -} diff --git a/aine/src/main.ts b/aine/src/main.ts deleted file mode 100644 index b1f2754..0000000 --- a/aine/src/main.ts +++ /dev/null @@ -1,6 +0,0 @@ -import App from "./App.svelte"; -import { mount } from "svelte"; - -const app = mount(App, { target: document.getElementById("app")! }); - -export default app; diff --git a/aine/ssr-worker-client.ts b/aine/ssr-worker-client.ts deleted file mode 100644 index 7492402..0000000 --- a/aine/ssr-worker-client.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawn, type ChildProcess } from "node:child_process"; -import type { ChangedFile, RenderOptions } from "./types.js"; - - - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export interface DiffAnnotation { - side: "deletions" | "additions"; - lineNumber: number; -} - -export interface ThemeInit { - name: string; - shikiTheme: Record; - addBorder: string; - deleteBorder: string; -} - -const LARGE_FILE_THRESHOLD = 3000; - -function countLines(s: string | null): number { - if (!s) return 0; - let count = 1; - let pos = 0; - while ((pos = s.indexOf("\n", pos)) !== -1) { count++; pos++; } - return count; -} - -export function stripTrailingWS(s: string | null): string | null { - if (s == null) return s; - return s.replace(/[^\S\n]+$/gm, ""); -} - -/** - * Manages a Bun subprocess that renders diffs via @pierre/diffs SSR. - * Communicates via JSON lines over stdin/stdout. - * - * The worker must receive an initTheme message before it becomes ready. - */ -export class SSRWorker { - private proc: ChildProcess | null = null; - private ready = false; - private nextId = 0; - private pending = new Map< - number, - { resolve: (html: string) => void; reject: (err: Error) => void } - >(); - private buf = ""; - private starting: Promise | null = null; - private themeInit: ThemeInit | null = null; - - /** Set theme data before first ensure(). */ - setTheme(theme: ThemeInit) { - this.themeInit = theme; - } - - async ensure(): Promise { - if (this.proc && this.ready) return; - if (this.starting) return this.starting; - this.starting = new Promise((resolve, reject) => { - this.proc = spawn("bun", ["run", join(__dirname, "ssr-worker.ts")], { - stdio: ["pipe", "pipe", "inherit"], - cwd: __dirname, - }); - - this.proc.stdin!.on("error", () => {}); - - this.proc.on("exit", (code) => { - this.proc = null; - this.ready = false; - this.starting = null; - this.rejectAll("SSR worker exited: " + code); - }); - - this.proc.stdout!.on("data", (chunk: Buffer) => { - this.buf += chunk.toString(); - let nl: number; - while ((nl = this.buf.indexOf("\n")) !== -1) { - const line = this.buf.slice(0, nl); - this.buf = this.buf.slice(nl + 1); - if (!line.trim()) continue; - try { - const msg = JSON.parse(line); - if (msg.id === 0 && msg.ready) { - this.ready = true; - this.starting = null; - resolve(); - continue; - } - const cb = this.pending.get(msg.id); - if (cb) { - this.pending.delete(msg.id); - msg.error - ? cb.reject(new Error(msg.error)) - : cb.resolve(msg.html); - } - } catch {} - } - }); - - // Send init theme immediately after spawn - if (this.themeInit) { - try { - this.proc.stdin!.write( - JSON.stringify({ id: 0, initTheme: this.themeInit }) + "\n", - ); - } catch {} - } - - setTimeout(() => { - if (!this.ready) { - this.starting = null; - reject(new Error("SSR worker timeout")); - } - }, 15_000); - }); - return this.starting; - } - - preloadLangs(langs: string[]) { - if (!this.proc || !this.ready || langs.length === 0) return; - try { - this.proc.stdin!.write( - JSON.stringify({ id: -1, preloadLangs: langs }) + "\n", - ); - } catch {} - } - - async render( - file: ChangedFile, - oldContent: string | null, - newContent: string | null, - renderOpts?: RenderOptions, - annotations?: DiffAnnotation[], - ): Promise { - await this.ensure(); - const id = ++this.nextId; - - const lineCount = countLines(oldContent) + countLines(newContent); - const isLarge = lineCount > LARGE_FILE_THRESHOLD; - - const options: Record = {}; - if (renderOpts?.diffStyle) { - options.diffStyle = isLarge ? "split" : renderOpts.diffStyle; - } - if (renderOpts?.expandUnchanged !== undefined) { - options.expandUnchanged = isLarge ? false : renderOpts.expandUnchanged; - } - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - try { - const msg: Record = { - id, - oldFile: { - name: file.oldPath || file.path, - contents: oldContent ?? "", - }, - newFile: { name: file.path, contents: newContent ?? "" }, - options, - }; - if (annotations && annotations.length > 0) { - msg.annotations = annotations; - } - this.proc!.stdin!.write(JSON.stringify(msg) + "\n"); - } catch (e) { - this.pending.delete(id); - reject(new Error("Failed to write to worker: " + e)); - } - }); - } - - kill() { - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - this.ready = false; - } - this.rejectAll("Worker killed"); - } - - private rejectAll(msg: string) { - for (const [, cb] of this.pending) cb.reject(new Error(msg)); - this.pending.clear(); - } -} diff --git a/aine/ssr-worker.ts b/aine/ssr-worker.ts deleted file mode 100644 index 1a32737..0000000 --- a/aine/ssr-worker.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Persistent Bun subprocess for SSR diff rendering. - * Protocol: JSON lines over stdin/stdout. - * - * Init: { "id": 0, "initTheme": { name, shikiTheme, unsafeCSS } } - * Request: { "id": 1, "oldFile": {...}, "newFile": {...}, "options": {...} } - * Response: { "id": 1, "html": "..." } or { "id": 1, "error": "..." } - * Ready: { "id": 0, "ready": true } - * Preload: { "id": -1, "preloadLangs": ["python", "go", ...] } - */ - -import { createInterface } from "node:readline"; -import { preloadDiffHTML } from "@pierre/diffs/ssr"; -import { registerCustomTheme, preloadHighlighter } from "@pierre/diffs"; - -// Suppress EPIPE errors when parent process kills us -process.on("uncaughtException", (err: any) => { - if (err?.code === "EPIPE") process.exit(0); - throw err; -}); -process.stdout.on("error", (err: any) => { - if (err?.code === "EPIPE") process.exit(0); -}); - -let THEME_NAME = "aine-theme"; -let UNSAFE_CSS = ""; -let DEFAULT_OPTIONS: Record = { - diffIndicators: "none" as const, - diffStyle: "split" as const, - expandUnchanged: true, -}; - -function buildUnsafeCSS(addBorder: string, deleteBorder: string): string { - return ` - [data-line-type='change-addition'] { - box-shadow: inset 0 1px 0 ${addBorder}, inset 0 -1px 0 ${addBorder}; - } - [data-line-type='change-addition'] + [data-line-type='change-addition'] { - box-shadow: inset 0 -1px 0 ${addBorder}; - } - [data-line-type='change-addition']:has(+ [data-line-type='change-addition']) { - box-shadow: inset 0 1px 0 ${addBorder}; - } - [data-line-type='change-addition'] + [data-line-type='change-addition']:has(+ [data-line-type='change-addition']) { - box-shadow: none; - } - [data-line-type='change-deletion'] { - box-shadow: inset 0 1px 0 ${deleteBorder}, inset 0 -1px 0 ${deleteBorder}; - } - [data-line-type='change-deletion'] + [data-line-type='change-deletion'] { - box-shadow: inset 0 -1px 0 ${deleteBorder}; - } - [data-line-type='change-deletion']:has(+ [data-line-type='change-deletion']) { - box-shadow: inset 0 1px 0 ${deleteBorder}; - } - [data-line-type='change-deletion'] + [data-line-type='change-deletion']:has(+ [data-line-type='change-deletion']) { - box-shadow: none; - } -`; -} - -const rl = createInterface({ input: process.stdin }); -let initialized = false; - -for await (const line of rl) { - if (!line.trim()) continue; - let req: any; - try { - req = JSON.parse(line); - } catch { - continue; - } - - // Init message with theme data - if (req.initTheme && !initialized) { - initialized = true; - const { name, shikiTheme, addBorder, deleteBorder } = req.initTheme; - THEME_NAME = name || "aine-theme"; - UNSAFE_CSS = buildUnsafeCSS( - addBorder || "rgba(166, 227, 161, 0.25)", - deleteBorder || "rgba(243, 139, 168, 0.25)", - ); - DEFAULT_OPTIONS = { - ...DEFAULT_OPTIONS, - theme: THEME_NAME, - unsafeCSS: UNSAFE_CSS, - }; - - if (shikiTheme && Object.keys(shikiTheme).length > 0) { - registerCustomTheme(THEME_NAME, async () => ({ - ...shikiTheme, - name: THEME_NAME, - })); - } - - await preloadHighlighter({ - themes: [THEME_NAME], - langs: [ - "typescript", "javascript", "tsx", "jsx", - "json", "yaml", "toml", "markdown", - "css", "html", "svelte", - "python", "go", "rust", "bash", "shell", - ], - }).catch(() => {}); - - // Warmup - await preloadDiffHTML({ - oldFile: { name: "warmup.ts", contents: "const x = 1;" }, - newFile: { name: "warmup.ts", contents: "const x = 2;" }, - options: DEFAULT_OPTIONS, - }).catch(() => {}); - - try { process.stdout.write(JSON.stringify({ id: 0, ready: true }) + "\n"); } catch {} - continue; - } - - // Language preload - if (req.preloadLangs) { - preloadHighlighter({ themes: [], langs: req.preloadLangs }).catch(() => {}); - continue; - } - - // Render request - (async (r: any) => { - try { - const opts = { ...DEFAULT_OPTIONS, ...r.options }; - const html = await preloadDiffHTML({ - oldFile: r.oldFile, - newFile: r.newFile, - options: opts, - annotations: r.annotations, - }); - try { process.stdout.write(JSON.stringify({ id: r.id, html }) + "\n"); } catch {} - } catch (e) { - try { process.stdout.write(JSON.stringify({ id: r.id, error: String(e) }) + "\n"); } catch {} - } - })(req); -} diff --git a/aine/svelte.config.js b/aine/svelte.config.js deleted file mode 100644 index d0e6448..0000000 --- a/aine/svelte.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -export default { - preprocess: vitePreprocess(), -}; diff --git a/aine/tests/api/server.test.ts b/aine/tests/api/server.test.ts deleted file mode 100644 index 5811206..0000000 --- a/aine/tests/api/server.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { - startTestServer, - api, - apiPost, - type TestServer, -} from "../fixtures/test-server.js"; - -describe("API – basic endpoints", () => { - let server: TestServer; - - beforeAll(async () => { - server = await startTestServer(); - }, 15_000); - - afterAll(() => { - server.cleanup(); - }); - - it("GET /api/info returns repo info", async () => { - const { status, data } = await api(server.base, "/api/info"); - expect(status).toBe(200); - expect(data.name).toMatch(/aine-test-/); - expect(typeof data.branch).toBe("string"); - expect(data.fileCount).toBe(3); // modified, added, deleted - expect(typeof data.description).toBe("string"); - }); - - it("GET /api/files returns changed files", async () => { - const { status, data } = await api(server.base, "/api/files"); - expect(status).toBe(200); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(3); - - const paths = data.map((f: any) => f.path).sort(); - expect(paths).toEqual(["hello.ts", "newfile.ts", "utils.ts"]); - - const hello = data.find((f: any) => f.path === "hello.ts"); - expect(hello.status).toBe("modified"); - - const newfile = data.find((f: any) => f.path === "newfile.ts"); - expect(newfile.status).toBe("added"); - - const utils = data.find((f: any) => f.path === "utils.ts"); - expect(utils.status).toBe("deleted"); - }); - - it("GET /api/theme returns theme colors", async () => { - const { status, data } = await api(server.base, "/api/theme"); - expect(status).toBe(200); - expect(typeof data.bg).toBe("string"); - expect(typeof data.text).toBe("string"); - expect(typeof data.accent).toBe("string"); - }); - - it("GET /api/options returns render options", async () => { - const { status, data } = await api(server.base, "/api/options"); - expect(status).toBe(200); - expect(data.diffStyle).toMatch(/^(split|unified)$/); - expect(typeof data.expandUnchanged).toBe("boolean"); - expect(typeof data.ignoreWhitespace).toBe("boolean"); - }); - - it("POST /api/options updates and returns new options", async () => { - const { status, data } = await apiPost(server.base, "/api/options", { - diffStyle: "unified", - }); - expect(status).toBe(200); - expect(data.diffStyle).toBe("unified"); - - // Verify it persisted - const { data: opts2 } = await api(server.base, "/api/options"); - expect(opts2.diffStyle).toBe("unified"); - - // Reset - await apiPost(server.base, "/api/options", { diffStyle: "split" }); - }); - - it("GET /api/diff/:path returns HTML diff for modified file", async () => { - const { status, data } = await api( - server.base, - "/api/diff/" + encodeURIComponent("hello.ts"), - ); - expect(status).toBe(200); - expect(data.file.path).toBe("hello.ts"); - expect(typeof data.html).toBe("string"); - expect(data.html.length).toBeGreaterThan(0); - }); - - it("GET /api/diff/:path returns HTML diff for added file", async () => { - const { status, data } = await api( - server.base, - "/api/diff/" + encodeURIComponent("newfile.ts"), - ); - expect(status).toBe(200); - expect(data.file.path).toBe("newfile.ts"); - expect(data.html).toContain("newFile"); - }); - - it("GET /api/diff/:path returns HTML diff for deleted file", async () => { - const { status, data } = await api( - server.base, - "/api/diff/" + encodeURIComponent("utils.ts"), - ); - expect(status).toBe(200); - expect(data.file.path).toBe("utils.ts"); - expect(data.html).toContain("add"); - }); - - it("GET /api/diff/:path returns 404 for unknown file", async () => { - const { status, data } = await api( - server.base, - "/api/diff/" + encodeURIComponent("nonexistent.ts"), - ); - expect(status).toBe(404); - expect(data.error).toBeDefined(); - }); - - it("GET /api/refresh returns ok", async () => { - const { status, data } = await api(server.base, "/api/refresh"); - expect(status).toBe(200); - expect(data.ok).toBe(true); - }); - - it("GET /api/preload-status returns preload info", async () => { - const { status, data } = await api(server.base, "/api/preload-status"); - expect(status).toBe(200); - expect(typeof data.cached).toBe("number"); - expect(typeof data.total).toBe("number"); - expect(typeof data.preloading).toBe("boolean"); - }); -}); diff --git a/aine/tests/api/session.test.ts b/aine/tests/api/session.test.ts deleted file mode 100644 index ef3c0b8..0000000 --- a/aine/tests/api/session.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { - startTestServer, - api, - apiPost, - type TestServer, -} from "../fixtures/test-server.js"; - -describe("API – session management", () => { - let server: TestServer; - - beforeAll(async () => { - server = await startTestServer(); - }, 15_000); - - afterAll(() => { - server.cleanup(); - }); - - it("GET /api/session/context returns full session state", async () => { - const { status, data } = await api(server.base, "/api/session/context"); - expect(status).toBe(200); - // macOS resolves /var → /private/var, so normalize both - expect(data.repo.replace("/private/var", "/var")).toBe( - server.repo.dir.replace("/private/var", "/var"), - ); - expect(typeof data.repoName).toBe("string"); - expect(typeof data.branch).toBe("string"); - expect(typeof data.description).toBe("string"); - expect(Array.isArray(data.diffArgs)).toBe(true); - expect(Array.isArray(data.files)).toBe(true); - expect(data.files.length).toBe(3); - expect(typeof data.renderOptions).toBe("object"); - expect(typeof data.threadCount).toBe("number"); - }); - - it("POST /api/session/navigate sets current file", async () => { - const { status, data } = await apiPost( - server.base, - "/api/session/navigate", - { file: "hello.ts" }, - ); - expect(status).toBe(200); - expect(data.ok).toBe(true); - - // Verify via context - const { data: ctx } = await api(server.base, "/api/session/context"); - expect(ctx.selectedFile).toBe("hello.ts"); - }); - - it("POST /api/session/navigate with hunk and line", async () => { - const { status, data } = await apiPost( - server.base, - "/api/session/navigate", - { file: "hello.ts", hunk: 0, line: 1 }, - ); - expect(status).toBe(200); - expect(data.ok).toBe(true); - }); - - it("POST /api/session/reload reloads with new diffArgs", async () => { - const { status, data } = await apiPost( - server.base, - "/api/session/reload", - { diffArgs: ["HEAD~1..HEAD"], description: "Last commit" }, - ); - expect(status).toBe(200); - expect(data.ok).toBe(true); - - // Reload triggers a refresh – files should update. - // Wait a bit for async reload to settle. - await new Promise((r) => setTimeout(r, 500)); - - // Verify files changed (HEAD~1..HEAD = the initial commit shows no diff - // since there's only one commit in fixture, so file list may differ) - const { data: ctx } = await api(server.base, "/api/session/context"); - expect(ctx.repo).toBeDefined(); - expect(Array.isArray(ctx.files)).toBe(true); - }); - - it("SSE /api/events stream works", async () => { - // Connect to event stream - const controller = new AbortController(); - const eventsPromise = fetch(`${server.base}/api/events`, { - signal: controller.signal, - }); - - // Give SSE time to connect - await new Promise((r) => setTimeout(r, 300)); - - // Trigger a navigate event - await apiPost(server.base, "/api/session/navigate", { - file: "newfile.ts", - }); - - // Wait a bit for event to propagate - await new Promise((r) => setTimeout(r, 300)); - - // Abort and read what we got - controller.abort(); - - try { - const response = await eventsPromise; - const text = await response.text(); - // Should contain at least a navigate event - expect(text).toContain("data:"); - expect(text).toContain("navigate"); - } catch (e: any) { - // AbortError is expected - if (e.name !== "AbortError") throw e; - } - }); - - it("OPTIONS requests return CORS headers", async () => { - const r = await fetch(`${server.base}/api/info`, { method: "OPTIONS" }); - expect(r.status).toBe(204); - expect(r.headers.get("access-control-allow-origin")).toBe("*"); - }); - - it("Unknown API path returns HTML (SPA fallback)", async () => { - const r = await fetch(`${server.base}/unknown-path`); - expect(r.status).toBe(200); - expect(r.headers.get("content-type")).toContain("text/html"); - }); -}); diff --git a/aine/tests/api/threads.test.ts b/aine/tests/api/threads.test.ts deleted file mode 100644 index 9ba5849..0000000 --- a/aine/tests/api/threads.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { - startTestServer, - api, - apiPost, - apiPatch, - apiDelete, - type TestServer, -} from "../fixtures/test-server.js"; - -describe("API – threads CRUD", () => { - let server: TestServer; - - beforeAll(async () => { - server = await startTestServer(); - }, 15_000); - - afterAll(() => { - server.cleanup(); - }); - - it("GET /api/threads returns empty array initially", async () => { - const { status, data } = await api(server.base, "/api/threads"); - expect(status).toBe(200); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(0); - }); - - it("POST /api/threads creates a thread", async () => { - const { status, data } = await apiPost(server.base, "/api/threads", { - file: "hello.ts", - startLine: 1, - side: "new", - body: "This should use a constant", - severity: "suggestion", - }); - expect(status).toBe(200); - expect(data.id).toBeDefined(); - expect(data.file).toBe("hello.ts"); - expect(data.startLine).toBe(1); - expect(data.status).toBe("open"); - expect(data.comments).toHaveLength(1); - expect(data.comments[0].body).toBe("This should use a constant"); - expect(data.comments[0].severity).toBe("suggestion"); - }); - - it("GET /api/threads lists created threads", async () => { - const { data } = await api(server.base, "/api/threads"); - expect(data.length).toBeGreaterThanOrEqual(1); - expect(data[0].file).toBe("hello.ts"); - }); - - it("GET /api/threads?file=hello.ts filters by file", async () => { - // Create a thread on another file - await apiPost(server.base, "/api/threads", { - file: "newfile.ts", - startLine: 1, - side: "new", - body: "Another comment", - }); - - const { data: all } = await api(server.base, "/api/threads"); - expect(all.length).toBeGreaterThanOrEqual(2); - - const { data: filtered } = await api( - server.base, - "/api/threads?file=hello.ts", - ); - expect(filtered.every((t: any) => t.file === "hello.ts")).toBe(true); - }); - - it("GET /api/threads/:id returns single thread", async () => { - const { data: threads } = await api(server.base, "/api/threads"); - const id = threads[0].id; - - const { status, data } = await api(server.base, `/api/threads/${id}`); - expect(status).toBe(200); - expect(data.id).toBe(id); - expect(data.comments).toBeDefined(); - }); - - it("GET /api/threads/:id returns 404 for unknown id", async () => { - const { status, data } = await api( - server.base, - "/api/threads/nonexistent", - ); - expect(status).toBe(404); - expect(data.error).toBeDefined(); - }); - - it("PATCH /api/threads/:id/status resolves a thread", async () => { - const { data: threads } = await api(server.base, "/api/threads"); - const id = threads[0].id; - - const { status, data } = await apiPatch( - server.base, - `/api/threads/${id}/status`, - { status: "resolved" }, - ); - expect(status).toBe(200); - expect(data.status).toBe("resolved"); - }); - - it("GET /api/threads?status=open filters by status", async () => { - const { data } = await api(server.base, "/api/threads?status=open"); - expect(data.every((t: any) => t.status === "open")).toBe(true); - }); - - it("PATCH /api/threads/:id/edit edits thread body", async () => { - const { data: threads } = await api(server.base, "/api/threads"); - const id = threads[0].id; - - const { status, data } = await apiPatch( - server.base, - `/api/threads/${id}/edit`, - { body: "Updated comment text" }, - ); - expect(status).toBe(200); - // Verify the edit took effect - const { data: thread } = await api(server.base, `/api/threads/${id}`); - const lastComment = thread.comments[thread.comments.length - 1]; - expect(lastComment.body).toBe("Updated comment text"); - }); - - it("PATCH /api/threads/:id/status returns 404 for unknown id", async () => { - const { status } = await apiPatch( - server.base, - "/api/threads/nonexistent/status", - { status: "resolved" }, - ); - expect(status).toBe(404); - }); - - it("DELETE /api/threads/:id deletes a thread", async () => { - // Create a throwaway thread - const { data: created } = await apiPost(server.base, "/api/threads", { - file: "hello.ts", - startLine: 1, - side: "new", - body: "To be deleted", - }); - - const { status } = await apiDelete( - server.base, - `/api/threads/${created.id}`, - ); - expect(status).toBe(200); - - // Verify it's gone - const { status: getStatus } = await api( - server.base, - `/api/threads/${created.id}`, - ); - expect(getStatus).toBe(404); - }); - - it("DELETE /api/threads/:id returns 404 for unknown id", async () => { - const { status } = await apiDelete( - server.base, - "/api/threads/nonexistent", - ); - expect(status).toBe(404); - }); -}); diff --git a/aine/tests/coverage/base.css b/aine/tests/coverage/base.css deleted file mode 100644 index f418035..0000000 --- a/aine/tests/coverage/base.css +++ /dev/null @@ -1,224 +0,0 @@ -body, html { - margin:0; padding: 0; - height: 100%; -} -body { - font-family: Helvetica Neue, Helvetica, Arial; - font-size: 14px; - color:#333; -} -.small { font-size: 12px; } -*, *:after, *:before { - -webkit-box-sizing:border-box; - -moz-box-sizing:border-box; - box-sizing:border-box; - } -h1 { font-size: 20px; margin: 0;} -h2 { font-size: 14px; } -pre { - font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; - margin: 0; - padding: 0; - -moz-tab-size: 2; - -o-tab-size: 2; - tab-size: 2; -} -a { color:#0074D9; text-decoration:none; } -a:hover { text-decoration:underline; } -.strong { font-weight: bold; } -.space-top1 { padding: 10px 0 0 0; } -.pad2y { padding: 20px 0; } -.pad1y { padding: 10px 0; } -.pad2x { padding: 0 20px; } -.pad2 { padding: 20px; } -.pad1 { padding: 10px; } -.space-left2 { padding-left:55px; } -.space-right2 { padding-right:20px; } -.center { text-align:center; } -.clearfix { display:block; } -.clearfix:after { - content:''; - display:block; - height:0; - clear:both; - visibility:hidden; - } -.fl { float: left; } -@media only screen and (max-width:640px) { - .col3 { width:100%; max-width:100%; } - .hide-mobile { display:none!important; } -} - -.quiet { - color: #7f7f7f; - color: rgba(0,0,0,0.5); -} -.quiet a { opacity: 0.7; } - -.fraction { - font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; - font-size: 10px; - color: #555; - background: #E8E8E8; - padding: 4px 5px; - border-radius: 3px; - vertical-align: middle; -} - -div.path a:link, div.path a:visited { color: #333; } -table.coverage { - border-collapse: collapse; - margin: 10px 0 0 0; - padding: 0; -} - -table.coverage td { - margin: 0; - padding: 0; - vertical-align: top; -} -table.coverage td.line-count { - text-align: right; - padding: 0 5px 0 20px; -} -table.coverage td.line-coverage { - text-align: right; - padding-right: 10px; - min-width:20px; -} - -table.coverage td span.cline-any { - display: inline-block; - padding: 0 5px; - width: 100%; -} -.missing-if-branch { - display: inline-block; - margin-right: 5px; - border-radius: 3px; - position: relative; - padding: 0 4px; - background: #333; - color: yellow; -} - -.skip-if-branch { - display: none; - margin-right: 10px; - position: relative; - padding: 0 4px; - background: #ccc; - color: white; -} -.missing-if-branch .typ, .skip-if-branch .typ { - color: inherit !important; -} -.coverage-summary { - border-collapse: collapse; - width: 100%; -} -.coverage-summary tr { border-bottom: 1px solid #bbb; } -.keyline-all { border: 1px solid #ddd; } -.coverage-summary td, .coverage-summary th { padding: 10px; } -.coverage-summary tbody { border: 1px solid #bbb; } -.coverage-summary td { border-right: 1px solid #bbb; } -.coverage-summary td:last-child { border-right: none; } -.coverage-summary th { - text-align: left; - font-weight: normal; - white-space: nowrap; -} -.coverage-summary th.file { border-right: none !important; } -.coverage-summary th.pct { } -.coverage-summary th.pic, -.coverage-summary th.abs, -.coverage-summary td.pct, -.coverage-summary td.abs { text-align: right; } -.coverage-summary td.file { white-space: nowrap; } -.coverage-summary td.pic { min-width: 120px !important; } -.coverage-summary tfoot td { } - -.coverage-summary .sorter { - height: 10px; - width: 7px; - display: inline-block; - margin-left: 0.5em; - background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; -} -.coverage-summary .sorted .sorter { - background-position: 0 -20px; -} -.coverage-summary .sorted-desc .sorter { - background-position: 0 -10px; -} -.status-line { height: 10px; } -/* yellow */ -.cbranch-no { background: yellow !important; color: #111; } -/* dark red */ -.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } -.low .chart { border:1px solid #C21F39 } -.highlighted, -.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ - background: #C21F39 !important; -} -/* medium red */ -.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } -/* light red */ -.low, .cline-no { background:#FCE1E5 } -/* light green */ -.high, .cline-yes { background:rgb(230,245,208) } -/* medium green */ -.cstat-yes { background:rgb(161,215,106) } -/* dark green */ -.status-line.high, .high .cover-fill { background:rgb(77,146,33) } -.high .chart { border:1px solid rgb(77,146,33) } -/* dark yellow (gold) */ -.status-line.medium, .medium .cover-fill { background: #f9cd0b; } -.medium .chart { border:1px solid #f9cd0b; } -/* light yellow */ -.medium { background: #fff4c2; } - -.cstat-skip { background: #ddd; color: #111; } -.fstat-skip { background: #ddd; color: #111 !important; } -.cbranch-skip { background: #ddd !important; color: #111; } - -span.cline-neutral { background: #eaeaea; } - -.coverage-summary td.empty { - opacity: .5; - padding-top: 4px; - padding-bottom: 4px; - line-height: 1; - color: #888; -} - -.cover-fill, .cover-empty { - display:inline-block; - height: 12px; -} -.chart { - line-height: 0; -} -.cover-empty { - background: white; -} -.cover-full { - border-right: none !important; -} -pre.prettyprint { - border: none !important; - padding: 0 !important; - margin: 0 !important; -} -.com { color: #999 !important; } -.ignore-none { color: #999; font-weight: normal; } - -.wrapper { - min-height: 100%; - height: auto !important; - height: 100%; - margin: 0 auto -48px; -} -.footer, .push { - height: 48px; -} diff --git a/aine/tests/coverage/block-navigation.js b/aine/tests/coverage/block-navigation.js deleted file mode 100644 index 530d1ed..0000000 --- a/aine/tests/coverage/block-navigation.js +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable */ -var jumpToCode = (function init() { - // Classes of code we would like to highlight in the file view - var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; - - // Elements to highlight in the file listing view - var fileListingElements = ['td.pct.low']; - - // We don't want to select elements that are direct descendants of another match - var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` - - // Selector that finds elements on the page to which we can jump - var selector = - fileListingElements.join(', ') + - ', ' + - notSelector + - missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` - - // The NodeList of matching elements - var missingCoverageElements = document.querySelectorAll(selector); - - var currentIndex; - - function toggleClass(index) { - missingCoverageElements - .item(currentIndex) - .classList.remove('highlighted'); - missingCoverageElements.item(index).classList.add('highlighted'); - } - - function makeCurrent(index) { - toggleClass(index); - currentIndex = index; - missingCoverageElements.item(index).scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center' - }); - } - - function goToPrevious() { - var nextIndex = 0; - if (typeof currentIndex !== 'number' || currentIndex === 0) { - nextIndex = missingCoverageElements.length - 1; - } else if (missingCoverageElements.length > 1) { - nextIndex = currentIndex - 1; - } - - makeCurrent(nextIndex); - } - - function goToNext() { - var nextIndex = 0; - - if ( - typeof currentIndex === 'number' && - currentIndex < missingCoverageElements.length - 1 - ) { - nextIndex = currentIndex + 1; - } - - makeCurrent(nextIndex); - } - - return function jump(event) { - if ( - document.getElementById('fileSearch') === document.activeElement && - document.activeElement != null - ) { - // if we're currently focused on the search input, we don't want to navigate - return; - } - - switch (event.which) { - case 78: // n - case 74: // j - goToNext(); - break; - case 66: // b - case 75: // k - case 80: // p - goToPrevious(); - break; - } - }; -})(); -window.addEventListener('keydown', jumpToCode); diff --git a/aine/tests/coverage/clover.xml b/aine/tests/coverage/clover.xml deleted file mode 100644 index d8808d2..0000000 --- a/aine/tests/coverage/clover.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/aine/tests/coverage/coverage-final.json b/aine/tests/coverage/coverage-final.json deleted file mode 100644 index 952c277..0000000 --- a/aine/tests/coverage/coverage-final.json +++ /dev/null @@ -1,3 +0,0 @@ -{"/Users/tomsej/Workspace/pi-ext/aine/tests/fixtures/setup-repo.ts": {"path":"/Users/tomsej/Workspace/pi-ext/aine/tests/fixtures/setup-repo.ts","statementMap":{"0":{"start":{"line":25,"column":2},"end":{"line":25,"column":null}},"1":{"start":{"line":33,"column":8},"end":{"line":33,"column":55}},"2":{"start":{"line":36,"column":2},"end":{"line":36,"column":null}},"3":{"start":{"line":37,"column":2},"end":{"line":37,"column":null}},"4":{"start":{"line":38,"column":2},"end":{"line":38,"column":null}},"5":{"start":{"line":41,"column":2},"end":{"line":41,"column":null}},"6":{"start":{"line":42,"column":2},"end":{"line":42,"column":null}},"7":{"start":{"line":43,"column":2},"end":{"line":46,"column":null}},"8":{"start":{"line":47,"column":2},"end":{"line":47,"column":null}},"9":{"start":{"line":48,"column":2},"end":{"line":48,"column":null}},"10":{"start":{"line":51,"column":2},"end":{"line":51,"column":null}},"11":{"start":{"line":52,"column":2},"end":{"line":52,"column":null}},"12":{"start":{"line":53,"column":2},"end":{"line":53,"column":null}},"13":{"start":{"line":55,"column":2},"end":{"line":62,"column":null}},"14":{"start":{"line":58,"column":6},"end":{"line":60,"column":null}},"15":{"start":{"line":59,"column":8},"end":{"line":59,"column":null}},"16":{"start":{"line":69,"column":15},"end":{"line":69,"column":34}},"17":{"start":{"line":71,"column":2},"end":{"line":71,"column":null}},"18":{"start":{"line":72,"column":2},"end":{"line":72,"column":null}},"19":{"start":{"line":79,"column":8},"end":{"line":79,"column":55}},"20":{"start":{"line":81,"column":2},"end":{"line":81,"column":null}},"21":{"start":{"line":82,"column":2},"end":{"line":82,"column":null}},"22":{"start":{"line":83,"column":2},"end":{"line":83,"column":null}},"23":{"start":{"line":86,"column":2},"end":{"line":86,"column":null}},"24":{"start":{"line":87,"column":2},"end":{"line":87,"column":null}},"25":{"start":{"line":88,"column":2},"end":{"line":88,"column":null}},"26":{"start":{"line":89,"column":2},"end":{"line":89,"column":null}},"27":{"start":{"line":92,"column":2},"end":{"line":92,"column":null}},"28":{"start":{"line":93,"column":2},"end":{"line":93,"column":null}},"29":{"start":{"line":94,"column":2},"end":{"line":94,"column":null}},"30":{"start":{"line":95,"column":2},"end":{"line":95,"column":null}},"31":{"start":{"line":97,"column":2},"end":{"line":104,"column":null}},"32":{"start":{"line":100,"column":6},"end":{"line":102,"column":null}},"33":{"start":{"line":101,"column":8},"end":{"line":101,"column":null}}},"fnMap":{"0":{"name":"git","decl":{"start":{"line":24,"column":9},"end":{"line":24,"column":13}},"loc":{"start":{"line":24,"column":42},"end":{"line":25,"column":null}},"line":24},"1":{"name":"createFixtureRepo","decl":{"start":{"line":32,"column":16},"end":{"line":32,"column":49}},"loc":{"start":{"line":32,"column":49},"end":{"line":62,"column":null}},"line":32},"2":{"name":"(anonymous_2)","decl":{"start":{"line":57,"column":4},"end":{"line":57,"column":19}},"loc":{"start":{"line":57,"column":19},"end":{"line":60,"column":null}},"line":57},"3":{"name":"createStagedFixtureRepo","decl":{"start":{"line":68,"column":16},"end":{"line":68,"column":55}},"loc":{"start":{"line":68,"column":55},"end":{"line":72,"column":null}},"line":68},"4":{"name":"createTwoCommitRepo","decl":{"start":{"line":78,"column":16},"end":{"line":78,"column":51}},"loc":{"start":{"line":78,"column":51},"end":{"line":104,"column":null}},"line":78},"5":{"name":"(anonymous_5)","decl":{"start":{"line":99,"column":4},"end":{"line":99,"column":19}},"loc":{"start":{"line":99,"column":19},"end":{"line":102,"column":null}},"line":99}},"branchMap":{},"s":{"0":27,"1":4,"2":4,"3":4,"4":4,"5":4,"6":4,"7":4,"8":4,"9":4,"10":4,"11":4,"12":4,"13":4,"14":1,"15":1,"16":0,"17":0,"18":0,"19":1,"20":1,"21":1,"22":1,"23":1,"24":1,"25":1,"26":1,"27":1,"28":1,"29":1,"30":1,"31":1,"32":1,"33":1},"f":{"0":27,"1":4,"2":1,"3":0,"4":1,"5":1},"b":{},"meta":{"lastBranch":0,"lastFunction":6,"lastStatement":34,"seen":{"f:24:9:24:13":0,"s:25:2:25:Infinity":0,"f:32:16:32:49":1,"s:33:8:33:55":1,"s:36:2:36:Infinity":2,"s:37:2:37:Infinity":3,"s:38:2:38:Infinity":4,"s:41:2:41:Infinity":5,"s:42:2:42:Infinity":6,"s:43:2:46:Infinity":7,"s:47:2:47:Infinity":8,"s:48:2:48:Infinity":9,"s:51:2:51:Infinity":10,"s:52:2:52:Infinity":11,"s:53:2:53:Infinity":12,"s:55:2:62:Infinity":13,"f:57:4:57:19":2,"s:58:6:60:Infinity":14,"s:59:8:59:Infinity":15,"f:68:16:68:55":3,"s:69:15:69:34":16,"s:71:2:71:Infinity":17,"s:72:2:72:Infinity":18,"f:78:16:78:51":4,"s:79:8:79:55":19,"s:81:2:81:Infinity":20,"s:82:2:82:Infinity":21,"s:83:2:83:Infinity":22,"s:86:2:86:Infinity":23,"s:87:2:87:Infinity":24,"s:88:2:88:Infinity":25,"s:89:2:89:Infinity":26,"s:92:2:92:Infinity":27,"s:93:2:93:Infinity":28,"s:94:2:94:Infinity":29,"s:95:2:95:Infinity":30,"s:97:2:104:Infinity":31,"f:99:4:99:19":5,"s:100:6:102:Infinity":32,"s:101:8:101:Infinity":33}}} -,"/Users/tomsej/Workspace/pi-ext/aine/tests/fixtures/test-server.ts": {"path":"/Users/tomsej/Workspace/pi-ext/aine/tests/fixtures/test-server.ts","statementMap":{"0":{"start":{"line":11,"column":6},"end":{"line":11,"column":56}},"1":{"start":{"line":22,"column":2},"end":{"line":22,"column":null}},"2":{"start":{"line":32,"column":15},"end":{"line":32,"column":28}},"3":{"start":{"line":33,"column":15},"end":{"line":33,"column":27}},"4":{"start":{"line":34,"column":15},"end":{"line":34,"column":null}},"5":{"start":{"line":36,"column":8},"end":{"line":40,"column":4}},"6":{"start":{"line":43,"column":15},"end":{"line":43,"column":null}},"7":{"start":{"line":44,"column":2},"end":{"line":46,"column":null}},"8":{"start":{"line":45,"column":4},"end":{"line":45,"column":null}},"9":{"start":{"line":49,"column":18},"end":{"line":49,"column":null}},"10":{"start":{"line":50,"column":16},"end":{"line":50,"column":26}},"11":{"start":{"line":51,"column":2},"end":{"line":58,"column":null}},"12":{"start":{"line":52,"column":4},"end":{"line":55,"column":null}},"13":{"start":{"line":53,"column":16},"end":{"line":53,"column":47}},"14":{"start":{"line":54,"column":6},"end":{"line":54,"column":null}},"15":{"start":{"line":54,"column":16},"end":{"line":54,"column":null}},"16":{"start":{"line":58,"column":4},"end":{"line":58,"column":null}},"17":{"start":{"line":58,"column":29},"end":{"line":58,"column":47}},"18":{"start":{"line":62,"column":2},"end":{"line":68,"column":null}},"19":{"start":{"line":63,"column":14},"end":{"line":63,"column":45}},"20":{"start":{"line":64,"column":4},"end":{"line":64,"column":null}},"21":{"start":{"line":64,"column":15},"end":{"line":64,"column":null}},"22":{"start":{"line":66,"column":4},"end":{"line":66,"column":null}},"23":{"start":{"line":67,"column":4},"end":{"line":67,"column":null}},"24":{"start":{"line":68,"column":4},"end":{"line":68,"column":null}},"25":{"start":{"line":71,"column":8},"end":{"line":74,"column":null}},"26":{"start":{"line":72,"column":4},"end":{"line":72,"column":null}},"27":{"start":{"line":72,"column":10},"end":{"line":72,"column":31}},"28":{"start":{"line":74,"column":4},"end":{"line":74,"column":null}},"29":{"start":{"line":74,"column":21},"end":{"line":74,"column":35}},"30":{"start":{"line":77,"column":2},"end":{"line":77,"column":null}},"31":{"start":{"line":82,"column":12},"end":{"line":82,"column":47}},"32":{"start":{"line":83,"column":15},"end":{"line":83,"column":29}},"33":{"start":{"line":84,"column":2},"end":{"line":84,"column":null}},"34":{"start":{"line":89,"column":2},"end":{"line":93,"column":null}},"35":{"start":{"line":98,"column":2},"end":{"line":102,"column":null}},"36":{"start":{"line":107,"column":2},"end":{"line":107,"column":null}}},"fnMap":{"0":{"name":"randomPort","decl":{"start":{"line":21,"column":9},"end":{"line":21,"column":30}},"loc":{"start":{"line":21,"column":30},"end":{"line":22,"column":null}},"line":21},"1":{"name":"startTestServer","decl":{"start":{"line":29,"column":22},"end":{"line":29,"column":null}},"loc":{"start":{"line":31,"column":23},"end":{"line":77,"column":null}},"line":31},"2":{"name":"(anonymous_2)","decl":{"start":{"line":44,"column":18},"end":{"line":44,"column":27}},"loc":{"start":{"line":44,"column":45},"end":{"line":46,"column":null}},"line":44},"3":{"name":"(anonymous_3)","decl":{"start":{"line":58,"column":14},"end":{"line":58,"column":23}},"loc":{"start":{"line":58,"column":29},"end":{"line":58,"column":47}},"line":58},"4":{"name":"(anonymous_4)","decl":{"start":{"line":71,"column":8},"end":{"line":71,"column":24}},"loc":{"start":{"line":71,"column":24},"end":{"line":74,"column":null}},"line":71},"5":{"name":"(anonymous_5)","decl":{"start":{"line":74,"column":4},"end":{"line":74,"column":21}},"loc":{"start":{"line":74,"column":21},"end":{"line":74,"column":35}},"line":74},"6":{"name":"api","decl":{"start":{"line":81,"column":22},"end":{"line":81,"column":35}},"loc":{"start":{"line":81,"column":121},"end":{"line":84,"column":null}},"line":81},"7":{"name":"apiPost","decl":{"start":{"line":88,"column":22},"end":{"line":88,"column":39}},"loc":{"start":{"line":88,"column":120},"end":{"line":93,"column":null}},"line":88},"8":{"name":"apiPatch","decl":{"start":{"line":97,"column":22},"end":{"line":97,"column":40}},"loc":{"start":{"line":97,"column":121},"end":{"line":102,"column":null}},"line":97},"9":{"name":"apiDelete","decl":{"start":{"line":106,"column":22},"end":{"line":106,"column":41}},"loc":{"start":{"line":106,"column":107},"end":{"line":107,"column":null}},"line":106}},"branchMap":{"0":{"loc":{"start":{"line":30,"column":2},"end":{"line":30,"column":null}},"type":"default-arg","locations":[{"start":{"line":30,"column":35},"end":{"line":30,"column":null}}],"line":30},"1":{"loc":{"start":{"line":54,"column":6},"end":{"line":54,"column":null}},"type":"if","locations":[{"start":{"line":54,"column":6},"end":{"line":54,"column":null}},{"start":{},"end":{}}],"line":54},"2":{"loc":{"start":{"line":64,"column":4},"end":{"line":64,"column":null}},"type":"if","locations":[{"start":{"line":64,"column":4},"end":{"line":64,"column":null}},{"start":{},"end":{}}],"line":64}},"s":{"0":3,"1":3,"2":3,"3":3,"4":3,"5":3,"6":3,"7":3,"8":0,"9":3,"10":3,"11":3,"12":9,"13":9,"14":3,"15":3,"16":6,"17":6,"18":3,"19":3,"20":3,"21":0,"22":0,"23":0,"24":0,"25":3,"26":3,"27":3,"28":3,"29":0,"30":3,"31":40,"32":40,"33":40,"34":9,"35":3,"36":2},"f":{"0":3,"1":3,"2":0,"3":6,"4":3,"5":0,"6":40,"7":9,"8":3,"9":2},"b":{"0":[3],"1":[3,0],"2":[0,3]},"meta":{"lastBranch":3,"lastFunction":10,"lastStatement":37,"seen":{"s:11:6:11:56":0,"f:21:9:21:30":0,"s:22:2:22:Infinity":1,"f:29:22:29:Infinity":1,"b:30:35:30:Infinity":0,"s:32:15:32:28":2,"s:33:15:33:27":3,"s:34:15:34:Infinity":4,"s:36:8:40:4":5,"s:43:15:43:Infinity":6,"s:44:2:46:Infinity":7,"f:44:18:44:27":2,"s:45:4:45:Infinity":8,"s:49:18:49:Infinity":9,"s:50:16:50:26":10,"s:51:2:58:Infinity":11,"s:52:4:55:Infinity":12,"s:53:16:53:47":13,"b:54:6:54:Infinity:undefined:undefined:undefined:undefined":1,"s:54:6:54:Infinity":14,"s:54:16:54:Infinity":15,"s:58:4:58:Infinity":16,"f:58:14:58:23":3,"s:58:29:58:47":17,"s:62:2:68:Infinity":18,"s:63:14:63:45":19,"b:64:4:64:Infinity:undefined:undefined:undefined:undefined":2,"s:64:4:64:Infinity":20,"s:64:15:64:Infinity":21,"s:66:4:66:Infinity":22,"s:67:4:67:Infinity":23,"s:68:4:68:Infinity":24,"s:71:8:74:Infinity":25,"f:71:8:71:24":4,"s:72:4:72:Infinity":26,"s:72:10:72:31":27,"s:74:4:74:Infinity":28,"f:74:4:74:21":5,"s:74:21:74:35":29,"s:77:2:77:Infinity":30,"f:81:22:81:35":6,"s:82:12:82:47":31,"s:83:15:83:29":32,"s:84:2:84:Infinity":33,"f:88:22:88:39":7,"s:89:2:93:Infinity":34,"f:97:22:97:40":8,"s:98:2:102:Infinity":35,"f:106:22:106:41":9,"s:107:2:107:Infinity":36}}} -} diff --git a/aine/tests/coverage/favicon.png b/aine/tests/coverage/favicon.png deleted file mode 100644 index c1525b811a167671e9de1fa78aab9f5c0b61cef7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 445 zcmeAS@N?(olHy`uVBq!ia0y~yU=RRd4kiW$h6xih%orFLTRdGHLn;`zo@efna1>#C zP+yQ1a@(xwR@vcOg|93^@4RTb&rJ?ebaY4}0YgS)l{}^%au- z_;}9Zak48mJg9F`WyPkDf8Sd#=J(R3x$KX>%X`|@oLYbHga7uVXX@u){?wjyMnO+1 zTuHI+XIsFTuRkt*ZISfi{Q1E1(+AH@-!uMvG(H%&t*H1$^$z2LHOBP|-?Q^PxVHDn zE8Sgx8|3U1=T6uDT=Zi0;l^IynQwJtgb(g2vAt1la&Mp7X_vDT_Q)j8Yy0|>yZ&F7 z>gzqTLa*+5sbwwg_2_Z>x$}aP)2jaN%AU;Neb~!aH0t4llQ(#3f3m3>T?$;c<;CUD z*(?#4ax%qTk1L*g*`0EZDPVC|MvOnx2~Y9;F|DGdfE4vw1A9SNBas(w7%RP$}py8DW05-uwO!uq6UDXGquW&3{VYqqD7g#7*w zi_^dSQSIK!D!Gi|=%q_%GNyQ3 - - - - Code coverage report for All files - - - - - - - - - -
-
-

All files

-
- -
- 87.32% - Statements - 62/71 -
- - -
- 60% - Branches - 3/5 -
- - -
- 81.25% - Functions - 13/16 -
- - -
- 89.39% - Lines - 59/66 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FileStatementsBranchesFunctionsLines
setup-repo.ts -
-
91.17%31/34100%0/083.33%5/691.17%31/34
test-server.ts -
-
83.78%31/3760%3/580%8/1087.5%28/32
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/aine/tests/coverage/prettify.css b/aine/tests/coverage/prettify.css deleted file mode 100644 index b317a7c..0000000 --- a/aine/tests/coverage/prettify.css +++ /dev/null @@ -1 +0,0 @@ -.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/aine/tests/coverage/prettify.js b/aine/tests/coverage/prettify.js deleted file mode 100644 index b322523..0000000 --- a/aine/tests/coverage/prettify.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/aine/tests/coverage/setup-repo.ts.html b/aine/tests/coverage/setup-repo.ts.html deleted file mode 100644 index 4046bd1..0000000 --- a/aine/tests/coverage/setup-repo.ts.html +++ /dev/null @@ -1,400 +0,0 @@ - - - - - - Code coverage report for setup-repo.ts - - - - - - - - - -
-
-

All files setup-repo.ts

-
- -
- 91.17% - Statements - 31/34 -
- - -
- 100% - Branches - 0/0 -
- - -
- 83.33% - Functions - 5/6 -
- - -
- 91.17% - Lines - 31/34 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -27x -  -  -  -  -  -  -  -4x -  -  -4x -4x -4x -  -  -4x -4x -4x -  -  -  -4x -4x -  -  -4x -4x -4x -  -4x -  -  -1x -1x -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -  -1x -  -1x -1x -1x -  -  -1x -1x -1x -1x -  -  -1x -1x -1x -1x -  -1x -  -  -1x -1x -  -  -  -  - 
/**
- * Creates a temporary git repo with known files for deterministic testing.
- *
- * Initial commit:
- *   hello.ts  → "export const hello = 'world';\n"
- *   readme.md → "# Test Repo\n\nThis is a test.\n"
- *   utils.ts  → "export function add(a: number, b: number) { return a + b; }\n"
- *
- * Working tree changes (unstaged):
- *   hello.ts  → modified ('world' → 'aine')
- *   newfile.ts → added
- *   utils.ts  → deleted
- */
-import { mkdtempSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
-import { execFileSync } from "node:child_process";
-import { join } from "node:path";
-import { tmpdir } from "node:os";
- 
-export interface FixtureRepo {
-  dir: string;
-  cleanup: () => void;
-}
- 
-function git(args: string[], cwd: string) {
-  execFileSync("git", args, { cwd, stdio: "pipe" });
-}
- 
-/**
- * Create a fixture repo with an initial commit and working tree changes.
- * Returns the repo directory and a cleanup function.
- */
-export function createFixtureRepo(): FixtureRepo {
-  const dir = mkdtempSync(join(tmpdir(), "aine-test-"));
- 
-  // Init repo
-  git(["init"], dir);
-  git(["config", "user.email", "test@test.com"], dir);
-  git(["config", "user.name", "Test"], dir);
- 
-  // Initial commit with 3 files
-  writeFileSync(join(dir, "hello.ts"), "export const hello = 'world';\n");
-  writeFileSync(join(dir, "readme.md"), "# Test Repo\n\nThis is a test.\n");
-  writeFileSync(
-    join(dir, "utils.ts"),
-    "export function add(a: number, b: number) { return a + b; }\n",
-  );
-  git(["add", "."], dir);
-  git(["commit", "-m", "initial"], dir);
- 
-  // Working tree changes (unstaged)
-  writeFileSync(join(dir, "hello.ts"), "export const hello = 'aine';\n");
-  writeFileSync(join(dir, "newfile.ts"), "export const newFile = true;\n");
-  unlinkSync(join(dir, "utils.ts"));
- 
-  return {
-    dir,
-    cleanup: () => {
-      try {
-        rmSync(dir, { recursive: true, force: true });
-      } catch {}
-    },
-  };
-}
- 
-/**
- * Create a fixture repo with staged changes (for --staged tests).
- */
-export function createStagedFixtureRepo(): FixtureRepo {
-  const repo = createFixtureRepo();
-  // Stage the hello.ts modification
-  git(["add", "hello.ts"], repo.dir);
-  return repo;
-}
- 
-/**
- * Create a fixture repo with two commits (for HEAD~1..HEAD tests).
- */
-export function createTwoCommitRepo(): FixtureRepo {
-  const dir = mkdtempSync(join(tmpdir(), "aine-test-"));
- 
-  git(["init"], dir);
-  git(["config", "user.email", "test@test.com"], dir);
-  git(["config", "user.name", "Test"], dir);
- 
-  // First commit
-  writeFileSync(join(dir, "hello.ts"), "export const hello = 'world';\n");
-  writeFileSync(join(dir, "readme.md"), "# Test Repo\n");
-  git(["add", "."], dir);
-  git(["commit", "-m", "first"], dir);
- 
-  // Second commit: modify hello.ts, add config.ts
-  writeFileSync(join(dir, "hello.ts"), "export const hello = 'aine';\nexport const version = 2;\n");
-  writeFileSync(join(dir, "config.ts"), "export default { debug: true };\n");
-  git(["add", "."], dir);
-  git(["commit", "-m", "second"], dir);
- 
-  return {
-    dir,
-    cleanup: () => {
-      try {
-        rmSync(dir, { recursive: true, force: true });
-      } catch {}
-    },
-  };
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/aine/tests/coverage/sort-arrow-sprite.png b/aine/tests/coverage/sort-arrow-sprite.png deleted file mode 100644 index 6ed68316eb3f65dec9063332d2f69bf3093bbfab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138 zcmeAS@N?(olHy`uVBq!ia0y~yU|?rpV36iuV_;y|;5OBmfq@~=)5S5Qf-$*3|3hQ4 zf`Qw!#U2L?e0W@$Vi_Zt#MxdR>HO#=WF|J7#c{m=56hexM(h6^Z(XQmpqg+jN#nrN tssE?!n)YuJ%U&k~fdV%9gAMEqevg;zSU<7i3IhWJgQu&X%Q~loCIA|(EZhJ9 diff --git a/aine/tests/coverage/sorter.js b/aine/tests/coverage/sorter.js deleted file mode 100644 index 4ed70ae..0000000 --- a/aine/tests/coverage/sorter.js +++ /dev/null @@ -1,210 +0,0 @@ -/* eslint-disable */ -var addSorting = (function() { - 'use strict'; - var cols, - currentSort = { - index: 0, - desc: false - }; - - // returns the summary table element - function getTable() { - return document.querySelector('.coverage-summary'); - } - // returns the thead element of the summary table - function getTableHeader() { - return getTable().querySelector('thead tr'); - } - // returns the tbody element of the summary table - function getTableBody() { - return getTable().querySelector('tbody'); - } - // returns the th element for nth column - function getNthColumn(n) { - return getTableHeader().querySelectorAll('th')[n]; - } - - function onFilterInput() { - const searchValue = document.getElementById('fileSearch').value; - const rows = document.getElementsByTagName('tbody')[0].children; - - // Try to create a RegExp from the searchValue. If it fails (invalid regex), - // it will be treated as a plain text search - let searchRegex; - try { - searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive - } catch (error) { - searchRegex = null; - } - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - let isMatch = false; - - if (searchRegex) { - // If a valid regex was created, use it for matching - isMatch = searchRegex.test(row.textContent); - } else { - // Otherwise, fall back to the original plain text search - isMatch = row.textContent - .toLowerCase() - .includes(searchValue.toLowerCase()); - } - - row.style.display = isMatch ? '' : 'none'; - } - } - - // loads the search box - function addSearchBox() { - var template = document.getElementById('filterTemplate'); - var templateClone = template.content.cloneNode(true); - templateClone.getElementById('fileSearch').oninput = onFilterInput; - template.parentElement.appendChild(templateClone); - } - - // loads all columns - function loadColumns() { - var colNodes = getTableHeader().querySelectorAll('th'), - colNode, - cols = [], - col, - i; - - for (i = 0; i < colNodes.length; i += 1) { - colNode = colNodes[i]; - col = { - key: colNode.getAttribute('data-col'), - sortable: !colNode.getAttribute('data-nosort'), - type: colNode.getAttribute('data-type') || 'string' - }; - cols.push(col); - if (col.sortable) { - col.defaultDescSort = col.type === 'number'; - colNode.innerHTML = - colNode.innerHTML + ''; - } - } - return cols; - } - // attaches a data attribute to every tr element with an object - // of data values keyed by column name - function loadRowData(tableRow) { - var tableCols = tableRow.querySelectorAll('td'), - colNode, - col, - data = {}, - i, - val; - for (i = 0; i < tableCols.length; i += 1) { - colNode = tableCols[i]; - col = cols[i]; - val = colNode.getAttribute('data-value'); - if (col.type === 'number') { - val = Number(val); - } - data[col.key] = val; - } - return data; - } - // loads all row data - function loadData() { - var rows = getTableBody().querySelectorAll('tr'), - i; - - for (i = 0; i < rows.length; i += 1) { - rows[i].data = loadRowData(rows[i]); - } - } - // sorts the table using the data for the ith column - function sortByIndex(index, desc) { - var key = cols[index].key, - sorter = function(a, b) { - a = a.data[key]; - b = b.data[key]; - return a < b ? -1 : a > b ? 1 : 0; - }, - finalSorter = sorter, - tableBody = document.querySelector('.coverage-summary tbody'), - rowNodes = tableBody.querySelectorAll('tr'), - rows = [], - i; - - if (desc) { - finalSorter = function(a, b) { - return -1 * sorter(a, b); - }; - } - - for (i = 0; i < rowNodes.length; i += 1) { - rows.push(rowNodes[i]); - tableBody.removeChild(rowNodes[i]); - } - - rows.sort(finalSorter); - - for (i = 0; i < rows.length; i += 1) { - tableBody.appendChild(rows[i]); - } - } - // removes sort indicators for current column being sorted - function removeSortIndicators() { - var col = getNthColumn(currentSort.index), - cls = col.className; - - cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); - col.className = cls; - } - // adds sort indicators for current column being sorted - function addSortIndicators() { - getNthColumn(currentSort.index).className += currentSort.desc - ? ' sorted-desc' - : ' sorted'; - } - // adds event listeners for all sorter widgets - function enableUI() { - var i, - el, - ithSorter = function ithSorter(i) { - var col = cols[i]; - - return function() { - var desc = col.defaultDescSort; - - if (currentSort.index === i) { - desc = !currentSort.desc; - } - sortByIndex(i, desc); - removeSortIndicators(); - currentSort.index = i; - currentSort.desc = desc; - addSortIndicators(); - }; - }; - for (i = 0; i < cols.length; i += 1) { - if (cols[i].sortable) { - // add the click event handler on the th so users - // dont have to click on those tiny arrows - el = getNthColumn(i).querySelector('.sorter').parentElement; - if (el.addEventListener) { - el.addEventListener('click', ithSorter(i)); - } else { - el.attachEvent('onclick', ithSorter(i)); - } - } - } - } - // adds sorting functionality to the UI - return function() { - if (!getTable()) { - return; - } - cols = loadColumns(); - loadData(); - addSearchBox(); - addSortIndicators(); - enableUI(); - }; -})(); - -window.addEventListener('load', addSorting); diff --git a/aine/tests/coverage/test-server.ts.html b/aine/tests/coverage/test-server.ts.html deleted file mode 100644 index bd25b60..0000000 --- a/aine/tests/coverage/test-server.ts.html +++ /dev/null @@ -1,409 +0,0 @@ - - - - - - Code coverage report for test-server.ts - - - - - - - - - -
-
-

All files test-server.ts

-
- -
- 83.78% - Statements - 31/37 -
- - -
- 60% - Branches - 3/5 -
- - -
- 80% - Functions - 8/10 -
- - -
- 87.5% - Lines - 28/32 -
- - -
-

- Press n or j to go to the next uncovered block, b, p or k for the previous block. -

- -
-
-

-
1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109  -  -  -  -  -  -  -  -  -  -3x -  -  -  -  -  -  -  -  -  -  -3x -  -  -  -  -  -  -  -  -  -3x -3x -3x -  -3x -  -  -  -  -  -  -3x -3x -  -  -  -  -3x -3x -3x -9x -9x -3x -  -  -  -6x -  -  -  -3x -3x -3x -  -  -  -  -  -  -3x -3x -  -3x -  -  -3x -  -  -  -  -40x -40x -40x -  -  -  -  -9x -  -  -  -  -  -  -  -  -3x -  -  -  -  -  -  -  -  -2x -  - 
/**
- * Test helper: starts a real Aine server against a fixture git repo.
- *
- * Uses Bun subprocess (since the server requires Bun runtime),
- * waits for it to be ready, returns base URL + cleanup.
- */
-import { spawn, type ChildProcess } from "node:child_process";
-import { join, resolve } from "node:path";
-import { createFixtureRepo, type FixtureRepo } from "./setup-repo.js";
- 
-const SERVER_SCRIPT = resolve(__dirname, "../../cli.ts");
- 
-export interface TestServer {
-  port: number;
-  base: string;
-  repo: FixtureRepo;
-  cleanup: () => void;
-}
- 
-/** Find a random available port */
-function randomPort(): number {
-  return 10000 + Math.floor(Math.random() * 50000);
-}
- 
-/**
- * Start a test server. Spawns `bun cli.ts` with --no-open and --port.
- * Waits until the server responds to /api/info.
- */
-export async function startTestServer(
-  repoFactory: () => FixtureRepo = createFixtureRepo,
-): Promise<TestServer> {
-  const repo = repoFactory();
-  const port = randomPort();
-  const base = `http://localhost:${port}`;
- 
-  const proc = spawn("bun", ["run", SERVER_SCRIPT, "--no-open", "--port", String(port)], {
-    cwd: repo.dir,
-    stdio: ["pipe", "pipe", "pipe"],
-    env: { ...process.env, NODE_ENV: "test" },
-  });
- 
-  // Collect stderr for debugging
-  let stderr = "";
-  proc.stderr?.on("data", (chunk: Buffer) => {
-    stderr += chunk.toString();
-  });
- 
-  // Wait for server to be ready (poll /api/info)
-  const maxWait = 10_000;
-  const start = Date.now();
-  while (Date.now() - start < maxWait) {
-    try {
-      const r = await fetch(`${base}/api/info`);
-      Eif (r.ok) break;
-    } catch {
-      // Not ready yet
-    }
-    await new Promise((r) => setTimeout(r, 200));
-  }
- 
-  // Verify it's actually up
-  try {
-    const r = await fetch(`${base}/api/info`);
-    Iif (!r.ok) throw new Error(`Server not ready: ${r.status}`);
-  } catch (e) {
-    proc.kill();
-    repo.cleanup();
-    throw new Error(`Server failed to start on port ${port}. stderr: ${stderr}\n${e}`);
-  }
- 
-  const cleanup = () => {
-    try { proc.kill(); } catch {}
-    // Give process time to die before cleaning up repo
-    setTimeout(() => repo.cleanup(), 500);
-  };
- 
-  return { port, base, repo, cleanup };
-}
- 
-/** Simple fetch helper that returns parsed JSON */
-export async function api<T = any>(base: string, path: string, init?: RequestInit): Promise<{ status: number; data: T }> {
-  const r = await fetch(`${base}${path}`, init);
-  const data = await r.json() as T;
-  return { status: r.status, data };
-}
- 
-/** POST JSON helper */
-export async function apiPost<T = any>(base: string, path: string, body: unknown): Promise<{ status: number; data: T }> {
-  return api<T>(base, path, {
-    method: "POST",
-    headers: { "Content-Type": "application/json" },
-    body: JSON.stringify(body),
-  });
-}
- 
-/** PATCH JSON helper */
-export async function apiPatch<T = any>(base: string, path: string, body: unknown): Promise<{ status: number; data: T }> {
-  return api<T>(base, path, {
-    method: "PATCH",
-    headers: { "Content-Type": "application/json" },
-    body: JSON.stringify(body),
-  });
-}
- 
-/** DELETE helper */
-export async function apiDelete<T = any>(base: string, path: string): Promise<{ status: number; data: T }> {
-  return api<T>(base, path, { method: "DELETE" });
-}
- 
- -
-
- - - - - - - - \ No newline at end of file diff --git a/aine/tests/e2e/diff-view.test.ts b/aine/tests/e2e/diff-view.test.ts deleted file mode 100644 index 8a58588..0000000 --- a/aine/tests/e2e/diff-view.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { startE2EServer, type E2EServer } from "./helpers.js"; - -let server: E2EServer; - -test.beforeAll(async () => { - server = await startE2EServer(); -}); - -test.afterAll(() => { - server.cleanup(); -}); - -test("diff view renders HTML for a modified file", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - await page.click("text=hello.ts"); - await page.waitForTimeout(2000); - - // Diff is rendered inside a shadow DOM, so we need evaluate to read it - const shadowText = await page.evaluate(() => { - const host = document.querySelector("[id*='diff'] div[style]") - || document.querySelector("#diff-view div"); - if (!host?.shadowRoot) return document.body.textContent || ""; - return host.shadowRoot.textContent || ""; - }); - const allText = (await page.textContent("body")) + " " + shadowText; - expect(allText).toContain("hello"); -}); - -test("diff view renders added file", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - await page.click("text=newfile.ts"); - await page.waitForTimeout(2000); - - // Content is in shadow DOM - const shadowText = await page.evaluate(() => { - const hosts = document.querySelectorAll("div"); - for (const el of hosts) { - if (el.shadowRoot?.textContent?.includes("newFile")) { - return el.shadowRoot.textContent; - } - } - return ""; - }); - expect(shadowText).toContain("newFile"); -}); - -test("diff view renders deleted file", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - await page.click("text=utils.ts"); - await page.waitForTimeout(1500); - - const body = await page.textContent("body"); - expect(body).toContain("add"); - expect(body).toContain("number"); -}); - -test("split view has two panes", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - await page.click("text=hello.ts"); - await page.waitForTimeout(1500); - - // @pierre/diffs split view renders into a container with two sides - // Check that the diff container has content (indicating rendering worked) - const diffArea = page.locator("[class*='diff']").first(); - if (await diffArea.count() > 0) { - const html = await diffArea.innerHTML(); - expect(html.length).toBeGreaterThan(100); - } -}); - -test("switching to unified view changes layout", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - await page.click("text=hello.ts"); - await page.waitForTimeout(1500); - - // Get initial HTML - const beforeHtml = await page.content(); - - // Switch to unified - await page.keyboard.press("s"); - await page.waitForTimeout(1500); - - // HTML should change (different diff layout) - const afterHtml = await page.content(); - // At minimum the page should still be functional - const text = await page.textContent("body"); - expect(text).toContain("hello"); -}); - -test("expand unchanged toggle works", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - await page.click("text=hello.ts"); - await page.waitForTimeout(1500); - - // Toggle expand unchanged with 'e' key - await page.keyboard.press("e"); - await page.waitForTimeout(1000); - - // Page should still work - const text = await page.textContent("body"); - expect(text).toContain("hello"); -}); diff --git a/aine/tests/e2e/helpers.ts b/aine/tests/e2e/helpers.ts deleted file mode 100644 index e995142..0000000 --- a/aine/tests/e2e/helpers.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * E2E test helpers: manages a real Aine server + fixture repo for Playwright tests. - */ -import { spawn, type ChildProcess } from "node:child_process"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { - createFixtureRepo, - type FixtureRepo, -} from "../fixtures/setup-repo.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI = resolve(__dirname, "../../cli.ts"); - -export interface E2EServer { - port: number; - url: string; - repo: FixtureRepo; - proc: ChildProcess; - cleanup: () => void; -} - -function randomPort(): number { - return 10000 + Math.floor(Math.random() * 50000); -} - -export async function startE2EServer(): Promise { - const repo = createFixtureRepo(); - const port = randomPort(); - const url = `http://localhost:${port}`; - - const proc = spawn("bun", ["run", CLI, "--no-open", "--port", String(port)], { - cwd: repo.dir, - stdio: ["pipe", "pipe", "pipe"], - }); - - // Wait for server - const maxWait = 15_000; - const start = Date.now(); - while (Date.now() - start < maxWait) { - try { - const r = await fetch(`${url}/api/info`); - if (r.ok) break; - } catch {} - await new Promise((r) => setTimeout(r, 200)); - } - - const cleanup = () => { - try { proc.kill(); } catch {} - setTimeout(() => repo.cleanup(), 500); - }; - - return { port, url, repo, proc, cleanup }; -} diff --git a/aine/tests/e2e/navigation.test.ts b/aine/tests/e2e/navigation.test.ts deleted file mode 100644 index f156a1d..0000000 --- a/aine/tests/e2e/navigation.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { startE2EServer, type E2EServer } from "./helpers.js"; - -let server: E2EServer; - -test.beforeAll(async () => { - server = await startE2EServer(); -}); - -test.afterAll(() => { - server.cleanup(); -}); - -test("page loads and shows sidebar with files", async ({ page }) => { - await page.goto(server.url); - - // Wait for sidebar to populate with file entries - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - // Should see our 3 fixture files - const text = await page.textContent("body"); - expect(text).toContain("hello.ts"); - expect(text).toContain("newfile.ts"); - expect(text).toContain("utils.ts"); -}); - -test("clicking a file shows its diff", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - // Click on hello.ts in sidebar - await page.click("text=hello.ts"); - - // Wait for diff to render – should show the file content - await page.waitForTimeout(1000); - const body = await page.textContent("body"); - // The diff should contain the old or new value - expect(body).toMatch(/world|aine/); -}); - -test("keyboard j/k navigates between files", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - // Wait for initial load - await page.waitForTimeout(500); - - // Press j to go to next file - await page.keyboard.press("j"); - await page.waitForTimeout(300); - - // Press k to go to previous file - await page.keyboard.press("k"); - await page.waitForTimeout(300); - - // Page should still be functional (no crash) - const text = await page.textContent("body"); - expect(text).toContain("hello.ts"); -}); - -test("? key toggles help overlay", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - // Open help - await page.keyboard.press("?"); - await page.waitForTimeout(300); - - // Help overlay should be visible with keyboard shortcuts - const body = await page.textContent("body"); - expect(body).toMatch(/keyboard|shortcuts|help/i); - - // Close help with Escape - await page.keyboard.press("Escape"); - await page.waitForTimeout(300); -}); - -test("s key toggles split/unified view", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - // Click a file first to have a diff visible - await page.click("text=hello.ts"); - await page.waitForTimeout(1000); - - // Toggle to unified - await page.keyboard.press("s"); - await page.waitForTimeout(500); - - // Toggle back to split - await page.keyboard.press("s"); - await page.waitForTimeout(500); - - // Page should still work - const text = await page.textContent("body"); - expect(text).toContain("hello.ts"); -}); - -test("b key toggles sidebar visibility", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - // Sidebar should be visible initially - const sidebar = page.locator("#sidebar").first(); - await expect(sidebar).toBeVisible(); - - // Toggle sidebar off - await page.keyboard.press("b"); - await page.waitForTimeout(300); - - // Toggle sidebar back on - await page.keyboard.press("b"); - await page.waitForTimeout(300); -}); - -test("toolbar shows repo info", async ({ page }) => { - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - const body = await page.textContent("body"); - // Should show file count or repo name - expect(body).toMatch(/3\s*(files?|changed)/i); -}); diff --git a/aine/tests/e2e/threads.test.ts b/aine/tests/e2e/threads.test.ts deleted file mode 100644 index 56821f6..0000000 --- a/aine/tests/e2e/threads.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { startE2EServer, type E2EServer } from "./helpers.js"; - -let server: E2EServer; - -test.beforeAll(async () => { - server = await startE2EServer(); -}); - -test.afterAll(() => { - server.cleanup(); -}); - -test("can create a thread via API and see it in UI", async ({ page }) => { - // Create thread via API first - await fetch(`${server.url}/api/threads`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - file: "hello.ts", - startLine: 1, - side: "new", - body: "E2E test comment", - severity: "nit", - }), - }); - - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - - // Select the file with the thread - await page.click("text=hello.ts"); - await page.waitForTimeout(1500); - - // The comment should appear somewhere on the page - const body = await page.textContent("body"); - expect(body).toContain("E2E test comment"); -}); - -test("thread count shows in sidebar or toolbar", async ({ page }) => { - // Create a thread - await fetch(`${server.url}/api/threads`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - file: "hello.ts", - startLine: 1, - side: "new", - body: "Count test comment", - }), - }); - - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - await page.waitForTimeout(500); - - // The page should reflect that there are threads - // (exact UI depends on implementation - just verify no crash) - const text = await page.textContent("body"); - expect(text).toBeDefined(); -}); - -test("resolving a thread via API updates UI", async ({ page }) => { - // Create a thread - const createRes = await fetch(`${server.url}/api/threads`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - file: "hello.ts", - startLine: 1, - side: "new", - body: "Thread to resolve", - }), - }); - const thread = await createRes.json(); - - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - await page.click("text=hello.ts"); - await page.waitForTimeout(1500); - - // Resolve via API - await fetch(`${server.url}/api/threads/${thread.id}/status`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: "resolved" }), - }); - - // Reload page to see update - await page.reload(); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - await page.click("text=hello.ts"); - await page.waitForTimeout(1500); - - // Page should still work (thread should show as resolved or be filtered) - const body = await page.textContent("body"); - expect(body).toBeDefined(); -}); - -test("deleting a thread via API removes it from UI", async ({ page }) => { - // Create a thread - const createRes = await fetch(`${server.url}/api/threads`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - file: "hello.ts", - startLine: 1, - side: "new", - body: "Thread to delete - unique marker", - }), - }); - const thread = await createRes.json(); - - await page.goto(server.url); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - await page.click("text=hello.ts"); - await page.waitForTimeout(1500); - - // Verify it's visible - let body = await page.textContent("body"); - expect(body).toContain("Thread to delete - unique marker"); - - // Delete via API - await fetch(`${server.url}/api/threads/${thread.id}`, { method: "DELETE" }); - - // Reload to see update - await page.reload(); - await page.waitForSelector("#sidebar", { timeout: 10_000 }); - await page.click("text=hello.ts"); - await page.waitForTimeout(1500); - - body = await page.textContent("body"); - expect(body).not.toContain("Thread to delete - unique marker"); -}); diff --git a/aine/tests/fixtures/setup-repo.ts b/aine/tests/fixtures/setup-repo.ts deleted file mode 100644 index 818a9b1..0000000 --- a/aine/tests/fixtures/setup-repo.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Creates a temporary git repo with known files for deterministic testing. - * - * Initial commit: - * hello.ts → "export const hello = 'world';\n" - * readme.md → "# Test Repo\n\nThis is a test.\n" - * utils.ts → "export function add(a: number, b: number) { return a + b; }\n" - * - * Working tree changes (unstaged): - * hello.ts → modified ('world' → 'aine') - * newfile.ts → added - * utils.ts → deleted - */ -import { mkdtempSync, writeFileSync, unlinkSync, rmSync } from "node:fs"; -import { execFileSync } from "node:child_process"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -export interface FixtureRepo { - dir: string; - cleanup: () => void; -} - -function git(args: string[], cwd: string) { - execFileSync("git", args, { cwd, stdio: "pipe" }); -} - -/** - * Create a fixture repo with an initial commit and working tree changes. - * Returns the repo directory and a cleanup function. - */ -export function createFixtureRepo(): FixtureRepo { - const dir = mkdtempSync(join(tmpdir(), "aine-test-")); - - // Init repo - git(["init"], dir); - git(["config", "user.email", "test@test.com"], dir); - git(["config", "user.name", "Test"], dir); - - // Initial commit with 3 files - writeFileSync(join(dir, "hello.ts"), "export const hello = 'world';\n"); - writeFileSync(join(dir, "readme.md"), "# Test Repo\n\nThis is a test.\n"); - writeFileSync( - join(dir, "utils.ts"), - "export function add(a: number, b: number) { return a + b; }\n", - ); - git(["add", "."], dir); - git(["commit", "-m", "initial"], dir); - - // Working tree changes (unstaged) - writeFileSync(join(dir, "hello.ts"), "export const hello = 'aine';\n"); - writeFileSync(join(dir, "newfile.ts"), "export const newFile = true;\n"); - unlinkSync(join(dir, "utils.ts")); - - return { - dir, - cleanup: () => { - try { - rmSync(dir, { recursive: true, force: true }); - } catch {} - }, - }; -} - -/** - * Create a fixture repo with staged changes (for --staged tests). - */ -export function createStagedFixtureRepo(): FixtureRepo { - const repo = createFixtureRepo(); - // Stage the hello.ts modification - git(["add", "hello.ts"], repo.dir); - return repo; -} - -/** - * Create a fixture repo with two commits (for HEAD~1..HEAD tests). - */ -export function createTwoCommitRepo(): FixtureRepo { - const dir = mkdtempSync(join(tmpdir(), "aine-test-")); - - git(["init"], dir); - git(["config", "user.email", "test@test.com"], dir); - git(["config", "user.name", "Test"], dir); - - // First commit - writeFileSync(join(dir, "hello.ts"), "export const hello = 'world';\n"); - writeFileSync(join(dir, "readme.md"), "# Test Repo\n"); - git(["add", "."], dir); - git(["commit", "-m", "first"], dir); - - // Second commit: modify hello.ts, add config.ts - writeFileSync(join(dir, "hello.ts"), "export const hello = 'aine';\nexport const version = 2;\n"); - writeFileSync(join(dir, "config.ts"), "export default { debug: true };\n"); - git(["add", "."], dir); - git(["commit", "-m", "second"], dir); - - return { - dir, - cleanup: () => { - try { - rmSync(dir, { recursive: true, force: true }); - } catch {} - }, - }; -} diff --git a/aine/tests/fixtures/test-server.ts b/aine/tests/fixtures/test-server.ts deleted file mode 100644 index 448382d..0000000 --- a/aine/tests/fixtures/test-server.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Test helper: starts a real Aine server against a fixture git repo. - * - * Uses Bun subprocess (since the server requires Bun runtime), - * waits for it to be ready, returns base URL + cleanup. - */ -import { spawn, type ChildProcess } from "node:child_process"; -import { join, resolve } from "node:path"; -import { createFixtureRepo, type FixtureRepo } from "./setup-repo.js"; - -const SERVER_SCRIPT = resolve(__dirname, "../../cli.ts"); - -export interface TestServer { - port: number; - base: string; - repo: FixtureRepo; - cleanup: () => void; -} - -/** Find a random available port */ -function randomPort(): number { - return 10000 + Math.floor(Math.random() * 50000); -} - -/** - * Start a test server. Spawns `bun cli.ts` with --no-open and --port. - * Waits until the server responds to /api/info. - */ -export async function startTestServer( - repoFactory: () => FixtureRepo = createFixtureRepo, -): Promise { - const repo = repoFactory(); - const port = randomPort(); - const base = `http://localhost:${port}`; - - const proc = spawn("bun", ["run", SERVER_SCRIPT, "--no-open", "--port", String(port)], { - cwd: repo.dir, - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, NODE_ENV: "test" }, - }); - - // Collect stderr for debugging - let stderr = ""; - proc.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - // Wait for server to be ready (poll /api/info) - const maxWait = 10_000; - const start = Date.now(); - while (Date.now() - start < maxWait) { - try { - const r = await fetch(`${base}/api/info`); - if (r.ok) break; - } catch { - // Not ready yet - } - await new Promise((r) => setTimeout(r, 200)); - } - - // Verify it's actually up - try { - const r = await fetch(`${base}/api/info`); - if (!r.ok) throw new Error(`Server not ready: ${r.status}`); - } catch (e) { - proc.kill(); - repo.cleanup(); - throw new Error(`Server failed to start on port ${port}. stderr: ${stderr}\n${e}`); - } - - const cleanup = () => { - try { proc.kill(); } catch {} - // Give process time to die before cleaning up repo - setTimeout(() => repo.cleanup(), 500); - }; - - return { port, base, repo, cleanup }; -} - -/** Simple fetch helper that returns parsed JSON */ -export async function api(base: string, path: string, init?: RequestInit): Promise<{ status: number; data: T }> { - const r = await fetch(`${base}${path}`, init); - const data = await r.json() as T; - return { status: r.status, data }; -} - -/** POST JSON helper */ -export async function apiPost(base: string, path: string, body: unknown): Promise<{ status: number; data: T }> { - return api(base, path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); -} - -/** PATCH JSON helper */ -export async function apiPatch(base: string, path: string, body: unknown): Promise<{ status: number; data: T }> { - return api(base, path, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); -} - -/** DELETE helper */ -export async function apiDelete(base: string, path: string): Promise<{ status: number; data: T }> { - return api(base, path, { method: "DELETE" }); -} diff --git a/aine/tests/fixtures/vitest-setup.ts b/aine/tests/fixtures/vitest-setup.ts deleted file mode 100644 index 316ddc1..0000000 --- a/aine/tests/fixtures/vitest-setup.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Vitest setup: polyfill Bun globals that don't exist in Node. - */ -import { createHash } from "node:crypto"; - -if (typeof globalThis.Bun === "undefined") { - (globalThis as any).Bun = { - hash(input: string | Buffer, seed?: number): bigint { - const h = createHash("sha256"); - if (seed !== undefined) h.update(String(seed)); - h.update(typeof input === "string" ? input : Buffer.from(input)); - const hex = h.digest("hex").slice(0, 16); // 64-bit - return BigInt("0x" + hex); - }, - }; -} diff --git a/aine/tests/playwright.config.ts b/aine/tests/playwright.config.ts deleted file mode 100644 index 8737395..0000000 --- a/aine/tests/playwright.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -export default defineConfig({ - testDir: "./e2e", - timeout: 30_000, - retries: 0, - workers: 1, // Serial – each test file starts its own server - use: { - headless: true, - viewport: { width: 1280, height: 720 }, - }, - projects: [ - { - name: "chromium", - use: { browserName: "chromium" }, - }, - ], -}); diff --git a/aine/tests/unit/git.test.ts b/aine/tests/unit/git.test.ts deleted file mode 100644 index 3d5b8b9..0000000 --- a/aine/tests/unit/git.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { - createFixtureRepo, - createTwoCommitRepo, - type FixtureRepo, -} from "../fixtures/setup-repo.js"; -import { - isGitRepo, - getRepoName, - getCurrentBranch, - getChangedFiles, - getOldNewContents, - parseDiffRefs, - parseRefs, - contentHash, -} from "../../git.js"; - -describe("git helpers – pure functions", () => { - it("parseDiffRefs: no args → HEAD vs working tree", () => { - const r = parseDiffRefs([]); - expect(r).toEqual({ oldRef: "HEAD", newRef: null }); - }); - - it("parseDiffRefs: single ref", () => { - const r = parseDiffRefs(["main"]); - expect(r).toEqual({ oldRef: "main", newRef: null }); - }); - - it("parseDiffRefs: range ref", () => { - const r = parseDiffRefs(["main..feature"]); - expect(r).toEqual({ oldRef: "main", newRef: "feature" }); - }); - - it("parseDiffRefs: range with empty left", () => { - const r = parseDiffRefs(["..feature"]); - expect(r).toEqual({ oldRef: "HEAD", newRef: "feature" }); - }); - - it("contentHash: same input → same hash", () => { - const h1 = contentHash("hello", "world"); - const h2 = contentHash("hello", "world"); - expect(h1).toBe(h2); - }); - - it("contentHash: different input → different hash", () => { - const h1 = contentHash("hello", "world"); - const h2 = contentHash("hello", "other"); - expect(h1).not.toBe(h2); - }); - - it("contentHash: null handling", () => { - const h1 = contentHash(null, "world"); - const h2 = contentHash("hello", null); - const h3 = contentHash(null, null); - expect(h1).not.toBe(h2); - expect(h1).not.toBe(h3); - }); -}); - -describe("git helpers – fixture repo", () => { - let repo: FixtureRepo; - - beforeAll(() => { - repo = createFixtureRepo(); - }); - - afterAll(() => { - repo.cleanup(); - }); - - it("isGitRepo returns true for git repos", () => { - expect(isGitRepo(repo.dir)).toBe(true); - }); - - it("isGitRepo returns false for non-repos", () => { - expect(isGitRepo("/tmp")).toBe(false); - }); - - it("getRepoName returns directory name", () => { - const name = getRepoName(repo.dir); - expect(name).toMatch(/^aine-test-/); - }); - - it("getCurrentBranch returns a branch name", () => { - const branch = getCurrentBranch(repo.dir); - expect(typeof branch).toBe("string"); - expect(branch.length).toBeGreaterThan(0); - }); - - it("getChangedFiles: working tree shows 3 changes", () => { - const files = getChangedFiles([], repo.dir); - expect(files.length).toBe(3); - - const paths = files.map((f) => f.path).sort(); - expect(paths).toEqual(["hello.ts", "newfile.ts", "utils.ts"]); - - const hello = files.find((f) => f.path === "hello.ts")!; - expect(hello.status).toBe("modified"); - expect(hello.additions).toBeGreaterThan(0); - - const newfile = files.find((f) => f.path === "newfile.ts")!; - expect(newfile.status).toBe("added"); - expect(newfile.untracked).toBe(true); - - const utils = files.find((f) => f.path === "utils.ts")!; - expect(utils.status).toBe("deleted"); - }); - - it("getOldNewContents: modified file has both old and new", () => { - const files = getChangedFiles([], repo.dir); - const hello = files.find((f) => f.path === "hello.ts")!; - const { oldContent, newContent } = getOldNewContents(hello, [], repo.dir); - - expect(oldContent).toContain("world"); - expect(newContent).toContain("aine"); - }); - - it("getOldNewContents: added untracked file has null old", () => { - const files = getChangedFiles([], repo.dir); - const nf = files.find((f) => f.path === "newfile.ts")!; - const { oldContent, newContent } = getOldNewContents(nf, [], repo.dir); - - expect(oldContent).toBeNull(); - expect(newContent).toContain("newFile"); - }); - - it("getOldNewContents: deleted file has null new", () => { - const files = getChangedFiles([], repo.dir); - const utils = files.find((f) => f.path === "utils.ts")!; - const { oldContent, newContent } = getOldNewContents(utils, [], repo.dir); - - expect(oldContent).toContain("add"); - expect(newContent).toBeNull(); - }); - - it("parseRefs: valid ref", () => { - const r = parseRefs(["HEAD"], repo.dir); - expect("diffArgs" in r).toBe(true); - }); - - it("parseRefs: invalid ref returns error", () => { - const r = parseRefs(["nonexistent-branch-xyz"], repo.dir); - expect("error" in r).toBe(true); - }); -}); - -describe("git helpers – two-commit repo", () => { - let repo: FixtureRepo; - - beforeAll(() => { - repo = createTwoCommitRepo(); - }); - - afterAll(() => { - repo.cleanup(); - }); - - it("getChangedFiles with HEAD~1..HEAD shows inter-commit changes", () => { - const files = getChangedFiles(["HEAD~1..HEAD"], repo.dir); - expect(files.length).toBe(2); - - const paths = files.map((f) => f.path).sort(); - expect(paths).toEqual(["config.ts", "hello.ts"]); - }); - - it("getOldNewContents with ref range reads from git objects", () => { - const files = getChangedFiles(["HEAD~1..HEAD"], repo.dir); - const hello = files.find((f) => f.path === "hello.ts")!; - const { oldContent, newContent } = getOldNewContents( - hello, - ["HEAD~1..HEAD"], - repo.dir, - ); - - expect(oldContent).toContain("world"); - expect(newContent).toContain("aine"); - expect(newContent).toContain("version"); - }); -}); diff --git a/aine/tests/unit/lru-cache.test.ts b/aine/tests/unit/lru-cache.test.ts deleted file mode 100644 index c1e677a..0000000 --- a/aine/tests/unit/lru-cache.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { LRUCache } from "../../lru-cache.js"; - -describe("LRUCache", () => { - it("get/set basics", () => { - const c = new LRUCache(3); - c.set("a", 1); - c.set("b", 2); - expect(c.get("a")).toBe(1); - expect(c.get("b")).toBe(2); - expect(c.get("x")).toBeUndefined(); - expect(c.size).toBe(2); - }); - - it("evicts oldest when over capacity", () => { - const c = new LRUCache(3); - c.set("a", 1); - c.set("b", 2); - c.set("c", 3); - c.set("d", 4); // evicts "a" - expect(c.get("a")).toBeUndefined(); - expect(c.get("b")).toBe(2); - expect(c.get("d")).toBe(4); - expect(c.size).toBe(3); - }); - - it("get promotes to most-recently-used", () => { - const c = new LRUCache(3); - c.set("a", 1); - c.set("b", 2); - c.set("c", 3); - c.get("a"); // promote "a" - c.set("d", 4); // should evict "b" (oldest after promotion) - expect(c.get("a")).toBe(1); - expect(c.get("b")).toBeUndefined(); - expect(c.get("c")).toBe(3); - expect(c.get("d")).toBe(4); - }); - - it("set overwrites and promotes existing key", () => { - const c = new LRUCache(3); - c.set("a", 1); - c.set("b", 2); - c.set("c", 3); - c.set("a", 10); // overwrite + promote - c.set("d", 4); // evicts "b" - expect(c.get("a")).toBe(10); - expect(c.get("b")).toBeUndefined(); - }); - - it("has() works without promoting", () => { - const c = new LRUCache(2); - c.set("a", 1); - c.set("b", 2); - expect(c.has("a")).toBe(true); - expect(c.has("x")).toBe(false); - }); - - it("delete() removes entry", () => { - const c = new LRUCache(3); - c.set("a", 1); - c.set("b", 2); - expect(c.delete("a")).toBe(true); - expect(c.get("a")).toBeUndefined(); - expect(c.delete("x")).toBe(false); - expect(c.size).toBe(1); - }); - - it("deleteByPrefix() removes matching entries", () => { - const c = new LRUCache(10); - c.set("file:a", 1); - c.set("file:b", 2); - c.set("theme:x", 3); - const count = c.deleteByPrefix("file:"); - expect(count).toBe(2); - expect(c.size).toBe(1); - expect(c.get("theme:x")).toBe(3); - }); - - it("clear() empties cache", () => { - const c = new LRUCache(3); - c.set("a", 1); - c.set("b", 2); - c.clear(); - expect(c.size).toBe(0); - expect(c.get("a")).toBeUndefined(); - }); - - it("capacity of 1", () => { - const c = new LRUCache(1); - c.set("a", "x"); - c.set("b", "y"); - expect(c.get("a")).toBeUndefined(); - expect(c.get("b")).toBe("y"); - expect(c.size).toBe(1); - }); -}); diff --git a/aine/tests/vitest.config.ts b/aine/tests/vitest.config.ts deleted file mode 100644 index 8b0e8a2..0000000 --- a/aine/tests/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "vitest/config"; -import { resolve } from "node:path"; - -export default defineConfig({ - test: { - root: resolve(__dirname), - include: ["unit/**/*.test.ts", "api/**/*.test.ts"], - setupFiles: ["./fixtures/vitest-setup.ts"], - testTimeout: 15_000, - hookTimeout: 15_000, - }, -}); diff --git a/aine/theme.ts b/aine/theme.ts deleted file mode 100644 index aae6018..0000000 --- a/aine/theme.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Theme loader for Aine. - * - * Theme search order: - * 1. .aine/themes/.json (per-repo) - * 2. ~/.config/aine/themes/.json (user custom) - * 3. /themes/.json (built-in) - * - * A theme file is a shiki-compatible JSON theme with an optional "aine" section - * for UI chrome colors. Minimal theme (just UI colors): - * - * { - * "name": "my-theme", - * "type": "dark", - * "aine": { - * "bg": "#1e1e2e", - * "sidebar": "#1e1e2e", - * "text": "#cdd6f4", - * "muted": "#7f849c", - * "accent": "#89b4fa", - * "border": "#313244", - * "add": "#a6e3a1", - * "delete": "#f38ba8", - * "modified": "#f9e2af" - * } - * } - * - * Full themes include shiki tokenColors for syntax highlighting. - * If no tokenColors are present, the built-in catppuccin-mocha is used as fallback. - */ - -import { readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { homedir } from "node:os"; -import { fileURLToPath } from "node:url"; -import type { ThemeColors } from "./types.js"; - - - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BUILTIN_THEMES_DIR = join(__dirname, "themes"); -const USER_THEMES_DIR = join(homedir(), ".config", "aine", "themes"); - -export interface LoadedTheme { - name: string; - type: "dark" | "light"; - /** Full shiki theme data (for SSR worker). */ - shikiTheme: Record; - /** UI chrome colors. */ - ui: ThemeColors; -} - -const DEFAULT_UI: ThemeColors = { - bg: "#15131e", - sidebar: "#15131e", - hover: "#2a2440", - active: "#45475a", - text: "#cdd6f4", - muted: "#7f849c", - accent: "#cba6f7", - border: "#313244", - add: "#a6e3a1", - delete: "#f38ba8", - modified: "#f9e2af", - addBg: "rgba(166, 227, 161, 0.07)", - deleteBg: "rgba(243, 139, 168, 0.07)", - addBorder: "rgba(166, 227, 161, 0.25)", - deleteBorder: "rgba(243, 139, 168, 0.25)", -}; - -function tryLoadJson(path: string): any | null { - try { - return JSON.parse(readFileSync(path, "utf-8")); - } catch { - return null; - } -} - -/** - * Load a theme by name. Searches repo → user → built-in directories. - * Falls back to catppuccin-mocha if not found. - */ -export function loadTheme(name: string, repoRoot?: string): LoadedTheme { - const filename = name.endsWith(".json") ? name : `${name}.json`; - - // Search order - const candidates: string[] = []; - if (repoRoot) candidates.push(join(repoRoot, ".aine", "themes", filename)); - candidates.push(join(USER_THEMES_DIR, filename)); - candidates.push(join(BUILTIN_THEMES_DIR, filename)); - - let themeData: any = null; - for (const path of candidates) { - themeData = tryLoadJson(path); - if (themeData) break; - } - - // Fallback to built-in catppuccin - if (!themeData) { - themeData = tryLoadJson(join(BUILTIN_THEMES_DIR, "catppuccin-mocha.json")); - } - - if (!themeData) { - // Absolute fallback — return defaults with no shiki theme - return { - name: "catppuccin-mocha", - type: "dark", - shikiTheme: {}, - ui: { ...DEFAULT_UI }, - }; - } - - // Extract aine UI colors - const aineSection = themeData.aine || {}; - const ui: ThemeColors = { ...DEFAULT_UI }; - for (const key of Object.keys(DEFAULT_UI) as (keyof ThemeColors)[]) { - if (typeof aineSection[key] === "string") { - ui[key] = aineSection[key]; - } - } - - // Build shiki theme (everything except aine section) - const { aine: _, ...shikiTheme } = themeData; - - // If theme has no tokenColors (UI-only theme), load catppuccin as shiki base - if (!shikiTheme.tokenColors && !shikiTheme.settings) { - const base = tryLoadJson(join(BUILTIN_THEMES_DIR, "catppuccin-mocha.json")); - if (base) { - const { aine: __, ...baseShiki } = base; - shikiTheme.tokenColors = baseShiki.tokenColors; - shikiTheme.semanticHighlighting = baseShiki.semanticHighlighting; - shikiTheme.semanticTokenColors = baseShiki.semanticTokenColors; - if (!shikiTheme.colors) shikiTheme.colors = baseShiki.colors; - } - } - - // Override editor.background with aine.bg - if (!shikiTheme.colors) shikiTheme.colors = {}; - shikiTheme.colors["editor.background"] = ui.bg; - - return { - name: themeData.name || name, - type: themeData.type || "dark", - shikiTheme, - ui, - }; -} - - diff --git a/aine/themes/catppuccin-mocha.json b/aine/themes/catppuccin-mocha.json deleted file mode 100644 index e117368..0000000 --- a/aine/themes/catppuccin-mocha.json +++ /dev/null @@ -1,973 +0,0 @@ -{ - "name": "Catppuccin Monokai Mocha", - "type": "dark", - "semanticHighlighting": true, - "semanticTokenColors": { - "enumMember": { - "foreground": "#cba6f7" - }, - "namespace": { - "foreground": "#cdd6f4" - }, - "concept": { - "foreground": "#f5e0dc" - } - }, - "tokenColors": [ - { - "name": "Comment", - "scope": [ - "comment", - "string.comment" - ], - "settings": { - "fontStyle": "italic", - "foreground": "#6c7086" - } - }, - { - "name": "String", - "scope": [ - "string", - "string.template" - ], - "settings": { - "foreground": "#f9e2af" - } - }, - { - "name": "Number", - "scope": "constant.numeric", - "settings": { - "foreground": "#cba6f7" - } - }, - { - "name": "Embedded String Begin/End", - "scope": [ - "string.embedded.begin", - "string.embedded.end", - "punctuation.definition.template-expression", - "punctuation.section.embedded" - ], - "settings": { - "foreground": "#cba6f7" - } - }, - { - "name": "Default Embedded Color", - "scope": [ - "punctuation.section.embedded.begin.js", - "punctuation.section.embedded.end.js", - "punctuation.section.embedded.begin.erb", - "punctuation.section.embedded.end.erb", - "source.elixir.embedded", - "punctuation.separator", - "punctuation.accessor", - "meta.brace" - ], - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "Built-in constant", - "scope": "constant.language", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "User-defined constant", - "scope": [ - "constant.character", - "constant.other" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Language Variable", - "scope": "variable.language", - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "Keyword", - "scope": [ - "keyword", - "keyword.operator.logical", - "keyword.operator.constructor" - ], - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "Keyword Operator", - "scope": "keyword.operator", - "settings": { - "fontStyle": "bold", - "foreground": "#f38ba8" - } - }, - { - "name": "Storage", - "scope": "storage", - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "Types", - "scope": "storage.type", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Class", - "scope": [ - "entity.name.class", - "entity.name.module", - "entity.name.type", - "storage.identifier", - "support.class" - ], - "settings": { - "foreground": "#89b4fa" - } - }, - { - "name": "Variable Object", - "scope": [ - "variable.other.object", - "variable.other.constant", - "variable.other.global", - "variable.other.readwrite.class", - "variable.other.readwrite.instance", - "variable.other.readwrite.batchfile", - "variable.readwrite", - "variable.readwrite.other.block" - ], - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "Other variable", - "scope": [ - "variable.other", - "variable.other.property", - "variable.other.block" - ], - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "Inherited Class", - "scope": "entity.other.inherited-class", - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Package Import", - "scope": [ - "storage.modifier.import", - "storage.modifier.package" - ], - "settings": { - "foreground": "#89b4fa" - } - }, - { - "name": "Function name", - "scope": [ - "entity.name.function", - "support.function" - ], - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Function argument", - "scope": [ - "variable.parameter", - "entity.name.variable.parameter", - "parameter.variable" - ], - "settings": { - "fontStyle": "italic", - "foreground": "#fab387" - } - }, - { - "name": "Function call", - "scope": "entity.name.function-call", - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "Builtin Functions", - "scope": [ - "function.support.builtin", - "function.support.core" - ], - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Tag Name", - "scope": [ - "entity.name.tag", - "entity.name.tag.class.js" - ], - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "Tag Class and ID", - "scope": [ - "entity.name.tag.class", - "entity.name.tag.id" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Tag Attribute", - "scope": "entity.other.attribute-name", - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Library constant", - "scope": "support.constant", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Library class/type", - "scope": [ - "support.type", - "support.variable" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Json Property", - "scope": "support.dictionary.json", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "StyleSheet Property name", - "scope": [ - "support.type.property-name.css", - "support.type.property-name.scss", - "support.type.property-name.less", - "support.type.property-name.sass" - ], - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "CSS: Pseudo Attribute Names", - "scope": [ - "entity.other.attribute-name.pseudo-class.css", - "entity.other.attribute-name.pseudo-class.scss", - "entity.other.attribute-name.pseudo-class.less", - "entity.other.attribute-name.pseudo-class.sass", - "entity.other.attribute-name.pseudo-element.css", - "entity.other.attribute-name.pseudo-element.scss", - "entity.other.attribute-name.pseudo-element.less", - "entity.other.attribute-name.pseudo-element.sass" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "StyleSheet Property value", - "scope": [ - "support.constant.css", - "support.constant.scss", - "support.constant.less", - "support.constant.sass" - ], - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "StyleSheet Variable", - "scope": [ - "variable.css", - "variable.scss", - "variable.less", - "variable.sass" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "StyleSheet Variable String", - "scope": [ - "variable.css.string", - "variable.scss.string", - "variable.less.string", - "variable.sass.string" - ], - "settings": { - "foreground": "#f9e2af" - } - }, - { - "name": "StyleSheet Unit", - "scope": [ - "unit.css", - "unit.scss", - "unit.less", - "unit.sass" - ], - "settings": { - "foreground": "#cba6f7" - } - }, - { - "name": "StyleSheet Function", - "scope": [ - "function.css", - "function.scss", - "function.less", - "function.sass" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Library variable", - "scope": "support.other.variable", - "settings": { - "fontStyle": "" - } - }, - { - "name": "Invalid", - "scope": "invalid", - "settings": { - "background": "#cba6f7", - "foreground": "#f5e0dc" - } - }, - { - "name": "Invalid deprecated", - "scope": "invalid.deprecated", - "settings": { - "background": "#89dceb", - "foreground": "#f5e0dc" - } - }, - { - "name": "JSON String", - "scope": "support.type.property-name.json", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Link", - "scope": "string.detected-link", - "settings": { - "foreground": "#89b4fa" - } - }, - { - "name": "diff.header", - "scope": [ - "meta.diff", - "meta.diff.header" - ], - "settings": { - "foreground": "#75715E" - } - }, - { - "name": "diff.deleted", - "scope": "markup.deleted", - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "diff.inserted", - "scope": "markup.inserted", - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "diff.changed", - "scope": "markup.changed", - "settings": { - "foreground": "#f9e2af" - } - }, - { - "scope": "constant.numeric.line-number.find-in-files - match", - "settings": { - "foreground": "#56b6c2A0" - } - }, - { - "scope": "entity.name.filename.find-in-files", - "settings": { - "foreground": "#f9e2af" - } - }, - { - "name": "Markup: Emphasis", - "scope": "markup.italic, markup.italic.markdown", - "settings": { - "fontStyle": "italic" - } - }, - { - "name": "Markup: Punctuation", - "scope": [ - "punctuation.definition.italic.markdown", - "punctuation.definition.bold.markdown", - "punctuation.definition.heading.markdown" - ], - "settings": { - "foreground": "#6c7086" - } - }, - { - "name": "Markup: Emphasis Punctuation", - "scope": "punctuation.definition.italic.markdown", - "settings": { - "fontStyle": "italic" - } - }, - { - "name": "Markdown: Link", - "scope": "markup.underline.link.markdown", - "settings": { - "foreground": "#89b4fa" - } - }, - { - "name": "Markup: Bold", - "scope": "markup.bold.markdown", - "settings": { - "fontStyle": "bold" - } - }, - { - "name": "Markup: Heading", - "scope": "markup.heading.markdown", - "settings": { - "fontStyle": "bold", - "foreground": "#f38ba8" - } - }, - { - "name": "Markup: Quote", - "scope": "markup.quote.markdown", - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Markup: Separator", - "scope": "meta.separator.markdown", - "settings": { - "foreground": "#cba6f7", - "fontStyle": "bold" - } - }, - { - "name": "Markup: Raw", - "scope": [ - "markup.raw.inline.markdown", - "markup.raw.block.markdown" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Markup: List Punctuation", - "scope": "punctuation.definition.list_item.markdown", - "settings": { - "fontStyle": "bold", - "foreground": "#ffffff" - } - } - ], - "colors": { - "focusBorder": "#00000000", - "foreground": "#cdd6f4", - "disabledForeground": "#a6adc8", - "widget.shadow": "#17131F7f", - "selection.background": "#585b70", - "descriptionForeground": "#cdd6f4", - "errorForeground": "#f38ba8", - "icon.foreground": "#cba6f7", - "sash.hoverBorder": "#cba6f7", - "window.activeBorder": "#00000000", - "window.inactiveBorder": "#00000000", - "textBlockQuote.background": "#17131F", - "textBlockQuote.border": "#17131F", - "textCodeBlock.background": "#17131F", - "textLink.activeForeground": "#89dceb", - "textLink.foreground": "#89b4fa", - "textPreformat.foreground": "#cdd6f4", - "textSeparator.foreground": "#cba6f7", - "activityBar.background": "#17131F", - "activityBar.foreground": "#cba6f7", - "activityBar.dropBar": "#585b7099", - "activityBar.inactiveForeground": "#6c7086", - "activityBar.border": "#00000000", - "activityBarBadge.background": "#cba6f7", - "activityBarBadge.foreground": "#17131F", - "activityBar.activeBorder": "#00000000", - "activityBar.activeBackground": "#00000000", - "activityBar.activeFocusBorder": "#00000000", - "badge.background": "#45475a", - "badge.foreground": "#cdd6f4", - "breadcrumb.activeSelectionForeground": "#cba6f7", - "breadcrumb.background": "#17131F", - "breadcrumb.focusForeground": "#cba6f7", - "breadcrumb.foreground": "#cdd6f4cc", - "breadcrumbPicker.background": "#17131F", - "button.background": "#cba6f7", - "button.foreground": "#17131F", - "button.border": "#00000000", - "button.separator": "#00000000", - "button.hoverBackground": "#dfbaff", - "button.secondaryForeground": "#cdd6f4", - "button.secondaryBackground": "#585b70", - "button.secondaryHoverBackground": "#6c6f84", - "checkbox.background": "#45475a", - "checkbox.border": "#00000000", - "checkbox.foreground": "#cba6f7", - "dropdown.background": "#17131F", - "dropdown.listBackground": "#585b70", - "dropdown.border": "#cba6f7", - "dropdown.foreground": "#cdd6f4", - "debugToolBar.background": "#17131F", - "debugToolBar.border": "#00000000", - "debugExceptionWidget.background": "#17131F", - "debugExceptionWidget.border": "#cba6f7", - "debugTokenExpression.number": "#fab387", - "debugTokenExpression.boolean": "#cba6f7", - "debugTokenExpression.string": "#a6e3a1", - "debugTokenExpression.error": "#f38ba8", - "debugIcon.breakpointForeground": "#f38ba8", - "debugIcon.breakpointDisabledForeground": "#f38ba899", - "debugIcon.breakpointUnverifiedForeground": "#17131F", - "debugIcon.breakpointCurrentStackframeForeground": "#585b70", - "debugIcon.breakpointStackframeForeground": "#585b70", - "debugIcon.startForeground": "#a6e3a1", - "debugIcon.pauseForeground": "#89b4fa", - "debugIcon.stopForeground": "#f38ba8", - "debugIcon.disconnectForeground": "#585b70", - "debugIcon.restartForeground": "#94e2d5", - "debugIcon.stepOverForeground": "#cba6f7", - "debugIcon.stepIntoForeground": "#cdd6f4", - "debugIcon.stepOutForeground": "#cdd6f4", - "debugIcon.continueForeground": "#a6e3a1", - "debugIcon.stepBackForeground": "#585b70", - "debugConsole.infoForeground": "#89b4fa", - "debugConsole.warningForeground": "#fab387", - "debugConsole.errorForeground": "#f38ba8", - "debugConsole.sourceForeground": "#f5e0dc", - "debugConsoleInputIcon.foreground": "#cdd6f4", - "diffEditor.border": "#585b70", - "diffEditor.insertedTextBackground": "#a6e3a119", - "diffEditor.removedTextBackground": "#f38ba819", - "diffEditor.insertedLineBackground": "#a6e3a126", - "diffEditor.removedLineBackground": "#f38ba826", - "diffEditor.diagonalFill": "#585b7099", - "diffEditorOverview.insertedForeground": "#a6e3a1cc", - "diffEditorOverview.removedForeground": "#f38ba8cc", - "editor.background": "#17131F", - "editor.findMatchBackground": "#585b70", - "editor.findMatchBorder": "#cba6f766", - "editor.findMatchHighlightBackground": "#fab38759", - "editor.findMatchHighlightBorder": "#00000000", - "editor.findRangeHighlightBackground": "#585b704c", - "editor.findRangeHighlightBorder": "#00000000", - "editor.foldBackground": "#89dceb3f", - "editor.foreground": "#cdd6f4", - "editor.hoverHighlightBackground": "#89dceb3f", - "editor.inactiveSelectionBackground": "#00000000", - "editor.lineHighlightBackground": "#cdd6f411", - "editor.lineHighlightBorder": "#17131F", - "editor.rangeHighlightBackground": "#89dceb3f", - "editor.rangeHighlightBorder": "#00000000", - "editor.selectionBackground": "#585b70", - "editor.selectionHighlightBackground": "#9399b266", - "editor.selectionHighlightBorder": "#89dceb33", - "editor.wordHighlightBackground": "#585b70b2", - "editor.wordHighlightStrongBackground": "#585b707f", - "editorBracketMatch.background": "#9399b219", - "editorBracketMatch.border": "#9399b2", - "editorCodeLens.foreground": "#7f849c", - "editorCursor.background": "#17131F", - "editorCursor.foreground": "#f5e0dc", - "editorError.background": "#00000000", - "editorError.border": "#00000000", - "editorError.foreground": "#f38ba8", - "editorGroup.border": "#585b70", - "editorGroup.dropBackground": "#585b7099", - "editorGroup.emptyBackground": "#17131F", - "editorGroupHeader.tabsBackground": "#17131F", - "editorGutter.addedBackground": "#a6e3a1", - "editorGutter.background": "#17131F", - "editorGutter.commentRangeForeground": "#9399b2", - "editorGutter.deletedBackground": "#f38ba8", - "editorGutter.foldingControlForeground": "#9399b2", - "editorGutter.modifiedBackground": "#89dceb", - "editorHoverWidget.background": "#17131F", - "editorHoverWidget.border": "#585b70", - "editorHoverWidget.foreground": "#cdd6f4", - "editorIndentGuide.activeBackground": "#585b70", - "editorIndentGuide.background": "#45475a", - "editorInfo.background": "#00000000", - "editorInfo.border": "#00000000", - "editorInfo.foreground": "#89b4fa", - "editorInlayHint.foreground": "#585b70", - "editorInlayHint.background": "#17131Fbf", - "editorInlayHint.typeForeground": "#bac2de", - "editorInlayHint.typeBackground": "#17131Fbf", - "editorInlayHint.parameterForeground": "#a6adc8", - "editorInlayHint.parameterBackground": "#17131Fbf", - "editorLineNumber.activeForeground": "#cba6f7", - "editorLineNumber.foreground": "#7f849c", - "editorLink.activeForeground": "#cba6f7", - "editorMarkerNavigation.background": "#17131F", - "editorMarkerNavigationError.background": "#f38ba8", - "editorMarkerNavigationInfo.background": "#89b4fa", - "editorMarkerNavigationWarning.background": "#f9e2af", - "editorOverviewRuler.background": "#17131F", - "editorOverviewRuler.border": "#cdd6f411", - "editorRuler.foreground": "#585b70", - "editor.stackFrameHighlightBackground": "#f9e2af26", - "editor.focusedStackFrameHighlightBackground": "#a6e3a126", - "editorStickyScrollHover.background": "#313244", - "editorSuggestWidget.background": "#17131F", - "editorSuggestWidget.border": "#585b70", - "editorSuggestWidget.foreground": "#cdd6f4", - "editorSuggestWidget.highlightForeground": "#cba6f7", - "editorSuggestWidget.selectedBackground": "#313244", - "editorWarning.background": "#00000000", - "editorWarning.border": "#00000000", - "editorWarning.foreground": "#fab387", - "editorWhitespace.foreground": "#9399b266", - "editorWidget.background": "#17131F", - "editorWidget.foreground": "#cdd6f4", - "editorWidget.resizeBorder": "#585b70", - "editorLightBulb.foreground": "#f9e2af", - "extensionButton.prominentForeground": "#17131F", - "extensionButton.prominentBackground": "#cba6f7", - "extensionButton.prominentHoverBackground": "#dfbaff", - "extensionBadge.remoteBackground": "#89b4fa", - "extensionBadge.remoteForeground": "#17131F", - "extensionIcon.starForeground": "#f9e2af", - "extensionIcon.verifiedForeground": "#a6e3a1", - "extensionIcon.preReleaseForeground": "#f5e0dc", - "extensionIcon.sponsorForeground": "#f5c2e7", - "gitDecoration.addedResourceForeground": "#a6e3a1", - "gitDecoration.conflictingResourceForeground": "#cba6f7", - "gitDecoration.deletedResourceForeground": "#f38ba8", - "gitDecoration.ignoredResourceForeground": "#6c7086", - "gitDecoration.modifiedResourceForeground": "#f9e2af", - "gitDecoration.stageDeletedResourceForeground": "#f38ba8", - "gitDecoration.stageModifiedResourceForeground": "#f9e2af", - "gitDecoration.submoduleResourceForeground": "#89b4fa", - "gitDecoration.untrackedResourceForeground": "#a6e3a1", - "input.background": "#313244", - "input.border": "#00000000", - "input.foreground": "#cdd6f4", - "input.placeholderForeground": "#cdd6f472", - "inputOption.activeBackground": "#89b4fa26", - "inputOption.activeBorder": "#17131F33", - "inputOption.activeForeground": "#cdd6f4", - "inputValidation.errorBackground": "#f38ba8", - "inputValidation.errorBorder": "#17131F33", - "inputValidation.errorForeground": "#17131F", - "inputValidation.infoBackground": "#89b4fa", - "inputValidation.infoBorder": "#17131F33", - "inputValidation.infoForeground": "#17131F", - "inputValidation.warningBackground": "#fab387", - "inputValidation.warningBorder": "#17131F33", - "inputValidation.warningForeground": "#17131F", - "list.activeSelectionBackground": "#45475a", - "list.activeSelectionForeground": "#cdd6f4", - "list.dropBackground": "#585b7099", - "list.focusBackground": "#313244", - "list.focusForeground": "#cdd6f4", - "list.focusOutline": "#00000000", - "list.highlightForeground": "#cba6f7", - "list.hoverBackground": "#17131F", - "list.hoverForeground": "#cdd6f4", - "list.inactiveSelectionBackground": "#45475a", - "list.inactiveSelectionForeground": "#cdd6f4", - "list.warningForeground": "#f9e2af", - "listFilterWidget.background": "#45475a", - "listFilterWidget.noMatchesOutline": "#f38ba8", - "listFilterWidget.outline": "#00000000", - "tree.indentGuidesStroke": "#6c7086", - "menu.background": "#17131F", - "menu.border": "#17131F7f", - "menu.foreground": "#cdd6f4", - "menu.selectionBackground": "#585b70", - "menu.selectionBorder": "#00000000", - "menu.selectionForeground": "#cdd6f4", - "menu.separatorBackground": "#585b70", - "menubar.selectionBackground": "#45475a", - "menubar.selectionForeground": "#cdd6f4", - "merge.commonContentBackground": "#45475a", - "merge.commonHeaderBackground": "#585b70", - "merge.currentContentBackground": "#a6e3a133", - "merge.currentHeaderBackground": "#a6e3a166", - "merge.incomingContentBackground": "#89b4fa33", - "merge.incomingHeaderBackground": "#89b4fa66", - "minimap.background": "#17131F7f", - "minimap.errorHighlight": "#f38ba8", - "minimap.findMatchHighlight": "#585b70", - "minimap.selectionHighlight": "#585b70", - "minimap.warningHighlight": "#f9e2af", - "minimapGutter.addedBackground": "#a6e3a1", - "minimapGutter.deletedBackground": "#f38ba8", - "minimapGutter.modifiedBackground": "#89dceb", - "notificationCenter.border": "#cba6f7", - "notificationCenterHeader.foreground": "#cdd6f4", - "notificationCenterHeader.background": "#17131F", - "notificationToast.border": "#cba6f7", - "notifications.foreground": "#cdd6f4", - "notifications.background": "#17131F", - "notifications.border": "#cba6f7", - "notificationLink.foreground": "#89b4fa", - "notificationsErrorIcon.foreground": "#f38ba8", - "notificationsWarningIcon.foreground": "#fab387", - "notificationsInfoIcon.foreground": "#89b4fa", - "panel.background": "#17131F", - "panel.border": "#585b70", - "panelSection.border": "#585b70", - "panelSection.dropBackground": "#585b7099", - "panelTitle.activeBorder": "#cdd6f4", - "panelTitle.activeForeground": "#cdd6f4", - "panelTitle.inactiveForeground": "#a6adc8", - "peekView.border": "#cba6f7", - "peekViewEditor.background": "#17131F", - "peekViewEditor.matchHighlightBackground": "#fab3873f", - "peekViewEditor.matchHighlightBorder": "#fab387", - "peekViewEditorGutter.background": "#17131F", - "peekViewResult.background": "#17131F", - "peekViewResult.fileForeground": "#cdd6f4", - "peekViewResult.lineForeground": "#cdd6f4", - "peekViewResult.matchHighlightBackground": "#fab3873f", - "peekViewResult.selectionBackground": "#313244", - "peekViewResult.selectionForeground": "#cdd6f4", - "peekViewTitle.background": "#17131F", - "peekViewTitleDescription.foreground": "#bac2deb2", - "peekViewTitleLabel.foreground": "#cdd6f4", - "pickerGroup.border": "#cba6f7", - "pickerGroup.foreground": "#cba6f7", - "progressBar.background": "#cba6f7", - "scrollbar.shadow": "#17131F", - "scrollbarSlider.activeBackground": "#31324466", - "scrollbarSlider.background": "#585b707f", - "scrollbarSlider.hoverBackground": "#6c7086", - "settings.focusedRowBackground": "#585b7033", - "settings.headerForeground": "#cdd6f4", - "settings.modifiedItemIndicator": "#cba6f7", - "settings.dropdownBackground": "#45475a", - "settings.dropdownListBorder": "#00000000", - "settings.textInputBackground": "#45475a", - "settings.textInputBorder": "#00000000", - "settings.numberInputBackground": "#45475a", - "settings.numberInputBorder": "#00000000", - "sideBar.background": "#17131F", - "sideBar.dropBackground": "#585b7099", - "sideBar.foreground": "#cdd6f4", - "sideBar.border": "#00000000", - "sideBarSectionHeader.background": "#17131F", - "sideBarSectionHeader.foreground": "#cdd6f4", - "sideBarTitle.foreground": "#cba6f7", - "sideBarTitle.background": "#17131F", - "statusBar.background": "#17131F", - "statusBar.foreground": "#cdd6f4", - "statusBar.border": "#00000000", - "statusBar.noFolderBackground": "#17131F", - "statusBar.noFolderForeground": "#cdd6f4", - "statusBar.debuggingBackground": "#fab387", - "statusBar.debuggingForeground": "#17131F", - "statusBarItem.remoteBackground": "#89b4fa", - "statusBarItem.remoteForeground": "#17131F", - "statusBarItem.activeBackground": "#585b7066", - "statusBarItem.hoverBackground": "#585b7033", - "statusBarItem.prominentForeground": "#cba6f7", - "statusBarItem.prominentBackground": "#00000000", - "statusBarItem.prominentHoverBackground": "#585b7033", - "statusBarItem.errorForeground": "#f38ba8", - "statusBarItem.errorBackground": "#00000000", - "statusBarItem.warningForeground": "#fab387", - "statusBarItem.warningBackground": "#00000000", - "commandCenter.foreground": "#bac2de", - "commandCenter.activeForeground": "#cba6f7", - "commandCenter.background": "#17131F", - "commandCenter.activeBackground": "#585b7033", - "commandCenter.border": "#cba6f7", - "tab.activeBackground": "#17131F", - "tab.activeBorder": "#cba6f7", - "tab.activeBorderTop": "#00000000", - "tab.activeForeground": "#cba6f7", - "tab.border": "#17131F", - "tab.inactiveBackground": "#17131F", - "tab.inactiveForeground": "#6c7086", - "terminal.ansiBlack": "#6c7086", - "terminal.ansiBlue": "#89b4fa", - "terminal.ansiBrightBlack": "#7f849c", - "terminal.ansiBrightBlue": "#89b4fa", - "terminal.ansiBrightCyan": "#89dceb", - "terminal.ansiBrightGreen": "#a6e3a1", - "terminal.ansiBrightMagenta": "#f5c2e7", - "terminal.ansiBrightRed": "#f38ba8", - "terminal.ansiBrightWhite": "#cdd6f4", - "terminal.ansiBrightYellow": "#f9e2af", - "terminal.ansiCyan": "#89dceb", - "terminal.ansiGreen": "#a6e3a1", - "terminal.ansiMagenta": "#f5c2e7", - "terminal.ansiRed": "#f38ba8", - "terminal.ansiWhite": "#9399b2", - "terminal.ansiYellow": "#f9e2af", - "terminal.border": "#585b70", - "terminal.foreground": "#cdd6f4", - "terminal.dropBackground": "#585b7099", - "terminal.selectionBackground": "#585b70", - "terminalCursor.background": "#17131F", - "terminalCursor.foreground": "#f5e0dc", - "titleBar.activeBackground": "#17131F", - "titleBar.activeForeground": "#cdd6f4", - "titleBar.inactiveBackground": "#17131F", - "titleBar.inactiveForeground": "#cdd6f47f", - "titleBar.border": "#00000000", - "welcomePage.tileBackground": "#17131F", - "welcomePage.progress.background": "#17131F", - "welcomePage.progress.foreground": "#cba6f7", - "walkThrough.embeddedEditorBackground": "#17131F4c", - "symbolIcon.textForeground": "#cdd6f4", - "symbolIcon.arrayForeground": "#fab387", - "symbolIcon.booleanForeground": "#cba6f7", - "symbolIcon.classForeground": "#f9e2af", - "symbolIcon.colorForeground": "#f5c2e7", - "symbolIcon.constantForeground": "#fab387", - "symbolIcon.constructorForeground": "#b4befe", - "symbolIcon.enumeratorForeground": "#f9e2af", - "symbolIcon.enumeratorMemberForeground": "#f9e2af", - "symbolIcon.eventForeground": "#f5c2e7", - "symbolIcon.fieldForeground": "#cdd6f4", - "symbolIcon.fileForeground": "#cba6f7", - "symbolIcon.folderForeground": "#cba6f7", - "symbolIcon.functionForeground": "#89b4fa", - "symbolIcon.interfaceForeground": "#f9e2af", - "symbolIcon.keyForeground": "#94e2d5", - "symbolIcon.keywordForeground": "#cba6f7", - "symbolIcon.methodForeground": "#89b4fa", - "symbolIcon.moduleForeground": "#cdd6f4", - "symbolIcon.namespaceForeground": "#f9e2af", - "symbolIcon.nullForeground": "#eba0ac", - "symbolIcon.numberForeground": "#fab387", - "symbolIcon.objectForeground": "#f9e2af", - "symbolIcon.operatorForeground": "#94e2d5", - "symbolIcon.packageForeground": "#f2cdcd", - "symbolIcon.propertyForeground": "#eba0ac", - "symbolIcon.referenceForeground": "#f9e2af", - "symbolIcon.snippetForeground": "#f2cdcd", - "symbolIcon.stringForeground": "#a6e3a1", - "symbolIcon.structForeground": "#94e2d5", - "symbolIcon.typeParameterForeground": "#eba0ac", - "symbolIcon.unitForeground": "#cdd6f4", - "symbolIcon.variableForeground": "#cdd6f4", - "charts.foreground": "#cdd6f4", - "charts.lines": "#bac2de", - "charts.red": "#f38ba8", - "charts.blue": "#89b4fa", - "charts.yellow": "#f9e2af", - "charts.orange": "#fab387", - "charts.green": "#a6e3a1", - "charts.purple": "#cba6f7", - "errorLens.errorBackground": "#f38ba826", - "errorLens.errorMessageBackground": "#f38ba826", - "errorLens.errorBackgroundLight": "#f38ba826", - "errorLens.errorForeground": "#f38ba8", - "errorLens.errorForegroundLight": "#f38ba8", - "errorLens.warningBackground": "#fab38726", - "errorLens.warningMessageBackground": "#fab38726", - "errorLens.warningBackgroundLight": "#fab38726", - "errorLens.warningForeground": "#fab387", - "errorLens.warningForegroundLight": "#fab387", - "errorLens.infoBackground": "#89b4fa26", - "errorLens.infoMessageBackground": "#89b4fa26", - "errorLens.infoBackgroundLight": "#89b4fa26", - "errorLens.infoForeground": "#89b4fa", - "errorLens.infoForegroundLight": "#89b4fa", - "errorLens.hintBackground": "#a6e3a126", - "errorLens.hintMessageBackground": "#a6e3a126", - "errorLens.hintBackgroundLight": "#a6e3a126", - "errorLens.hintForeground": "#a6e3a1", - "errorLens.hintForegroundLight": "#a6e3a1", - "errorLens.statusBarIconErrorForeground": "#f38ba8", - "errorLens.statusBarIconWarningForeground": "#fab387", - "errorLens.statusBarErrorForeground": "#f38ba8", - "errorLens.statusBarWarningForeground": "#fab387", - "errorLens.statusBarInfoForeground": "#89b4fa", - "errorLens.statusBarHintForeground": "#a6e3a1", - "editorBracketHighlight.foreground1": "#f38ba8", - "editorBracketHighlight.foreground2": "#fab387", - "editorBracketHighlight.foreground3": "#f9e2af", - "editorBracketHighlight.foreground4": "#a6e3a1", - "editorBracketHighlight.foreground5": "#74c7ec", - "editorBracketHighlight.foreground6": "#cba6f7", - "editorBracketHighlight.unexpectedBracket.foreground": "#eba0ac" - }, - "aine": { - "bg": "#15131e", - "sidebar": "#15131e", - "hover": "#2a2440", - "active": "#45475a", - "text": "#cdd6f4", - "muted": "#7f849c", - "accent": "#cba6f7", - "border": "#313244", - "add": "#a6e3a1", - "delete": "#f38ba8", - "modified": "#f9e2af", - "addBg": "rgba(166, 227, 161, 0.07)", - "deleteBg": "rgba(243, 139, 168, 0.07)", - "addBorder": "rgba(166, 227, 161, 0.25)", - "deleteBorder": "rgba(243, 139, 168, 0.25)" - } -} \ No newline at end of file diff --git a/aine/themes/github-dark.json b/aine/themes/github-dark.json deleted file mode 100644 index 1dc8f4a..0000000 --- a/aine/themes/github-dark.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "github-dark", - "type": "dark", - "aine": { - "bg": "#0d1117", - "sidebar": "#010409", - "hover": "#161b22", - "active": "#1f2937", - "text": "#e6edf3", - "muted": "#7d8590", - "accent": "#58a6ff", - "border": "#30363d", - "add": "#3fb950", - "delete": "#f85149", - "modified": "#d29922", - "addBg": "rgba(63, 185, 80, 0.10)", - "deleteBg": "rgba(248, 81, 73, 0.10)", - "addBorder": "rgba(63, 185, 80, 0.30)", - "deleteBorder": "rgba(248, 81, 73, 0.30)" - } -} diff --git a/aine/thread-store.ts b/aine/thread-store.ts deleted file mode 100644 index db7164d..0000000 --- a/aine/thread-store.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * In-memory thread/comment store for one Aine session. - * - * Manages CRUD for review threads and their comments. - * Emits events so the DiffSession can notify SSE subscribers - * and invalidate cached HTML when annotations change. - */ - -import type { - Thread, - ThreadComment, - ThreadStatus, - ThreadAuthor, - Severity, - SessionEvent, -} from "./types.js"; - -// ── Event callback ──────────────────────────────────────────── - -export type ThreadChangeCallback = (event: SessionEvent) => void; - -// ── ThreadStore ─────────────────────────────────────────────── - -export class ThreadStore { - private threads = new Map(); - private onChange: ThreadChangeCallback; - - constructor(onChange: ThreadChangeCallback) { - this.onChange = onChange; - } - - // ── Helpers ───────────────────────────────────────────────── - - private shortId(): string { - return crypto.randomUUID().replace(/-/g, "").slice(0, 12); - } - - /** Find thread by full ID or short prefix (≥4 chars). */ - find(idOrPrefix: string): Thread | null { - const direct = this.threads.get(idOrPrefix); - if (direct) return direct; - if (idOrPrefix.length >= 4) { - for (const t of this.threads.values()) { - if (t.id.startsWith(idOrPrefix)) return t; - } - } - return null; - } - - // ── CRUD ──────────────────────────────────────────────────── - - create(data: { - file: string; - startLine: number; - endLine?: number; - side?: "old" | "new"; - body: string; - severity?: Severity; - author?: ThreadAuthor; - anchorContent?: string; - }): Thread { - const threadId = this.shortId(); - const commentId = this.shortId(); - const now = new Date().toISOString(); - const author = data.author || { name: "user", type: "user" as const }; - - const thread: Thread = { - id: threadId, - file: data.file, - startLine: data.startLine, - endLine: data.endLine ?? data.startLine, - side: data.side || "new", - status: "open", - anchorContent: data.anchorContent ?? null, - createdAt: now, - updatedAt: now, - comments: [{ - id: commentId, - threadId, - author, - body: data.body, - severity: data.severity, - createdAt: now, - }], - }; - this.threads.set(threadId, thread); - this.onChange({ type: "thread-created", thread }); - return thread; - } - - addReply(threadId: string, body: string, author?: ThreadAuthor): ThreadComment | null { - const thread = this.find(threadId); - if (!thread) return null; - const comment: ThreadComment = { - id: this.shortId(), - threadId: thread.id, - author: author || { name: "user", type: "user" as const }, - body, - createdAt: new Date().toISOString(), - }; - thread.comments.push(comment); - thread.updatedAt = comment.createdAt; - this.onChange({ type: "thread-updated", thread }); - return comment; - } - - updateStatus(id: string, status: ThreadStatus): Thread | null { - const thread = this.find(id); - if (!thread) return null; - thread.status = status; - thread.updatedAt = new Date().toISOString(); - this.onChange({ type: "thread-updated", thread }); - return thread; - } - - edit(id: string, body: string): Thread | null { - const thread = this.find(id); - if (!thread || thread.comments.length === 0) return null; - thread.comments[0].body = body; - thread.updatedAt = new Date().toISOString(); - this.onChange({ type: "thread-updated", thread }); - return thread; - } - - delete(id: string): boolean { - const thread = this.find(id); - if (!thread) return false; - this.threads.delete(thread.id); - this.onChange({ type: "thread-deleted", id: thread.id }); - return true; - } - - // ── Queries ───────────────────────────────────────────────── - - get(id: string): Thread | null { - return this.find(id); - } - - list(filter?: { file?: string; status?: ThreadStatus }): Thread[] { - let all = [...this.threads.values()]; - if (filter?.file) all = all.filter(t => t.file === filter.file); - if (filter?.status) all = all.filter(t => t.status === filter.status); - return all; - } - - get count(): number { - return this.threads.size; - } - - /** Get threads for a specific file (used for diff annotations). */ - forFile(filePath: string): Thread[] { - return [...this.threads.values()].filter(t => t.file === filePath); - } -} diff --git a/aine/types.ts b/aine/types.ts deleted file mode 100644 index d4a831f..0000000 --- a/aine/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Shared types used by both backend (server, CLI) and frontend (Svelte UI). - * Single source of truth — no duplicates. - */ - -// ── File / Diff types ───────────────────────────────────────── - -export type FileStatus = "added" | "deleted" | "modified" | "renamed" | "copied"; - -export interface ChangedFile { - path: string; - oldPath?: string; - status: FileStatus; - untracked?: boolean; - additions: number; - deletions: number; -} - -export interface RepoInfo { - name: string; - branch: string; - description: string; - fileCount: number; - cached: number; - total: number; -} - -export interface RenderOptions { - diffStyle: "split" | "unified"; - expandUnchanged: boolean; - ignoreWhitespace: boolean; -} - -// ── Thread / Comment types ──────────────────────────────────── - -export type Severity = "must-fix" | "suggestion" | "nit"; -export type ThreadStatus = "open" | "resolved" | "dismissed"; - -export interface ThreadAuthor { - name: string; - type: "user" | "agent"; -} - -export interface ThreadComment { - id: string; - threadId: string; - author: ThreadAuthor; - body: string; - severity?: Severity; - createdAt: string; -} - -export interface Thread { - id: string; - file: string; - startLine: number; - endLine: number; - side: "old" | "new"; - status: ThreadStatus; - anchorContent: string | null; - createdAt: string; - updatedAt: string; - comments: ThreadComment[]; -} - -// ── Theme types ─────────────────────────────────────────────── - -export interface ThemeColors { - bg: string; - sidebar: string; - hover: string; - active: string; - text: string; - muted: string; - accent: string; - border: string; - add: string; - delete: string; - modified: string; - addBg: string; - deleteBg: string; - addBorder: string; - deleteBorder: string; -} - -// ── SSE event types ─────────────────────────────────────────── - -export type SessionEvent = - | { type: "thread-created"; thread: Thread } - | { type: "thread-updated"; thread: Thread } - | { type: "thread-deleted"; id: string } - | { type: "navigate"; file: string; hunk?: number; line?: number } - | { type: "reload"; diffArgs: string[]; description: string } - | { type: "refresh" } - | { type: "stale" }; diff --git a/aine/vite.config.ts b/aine/vite.config.ts deleted file mode 100644 index 18d243c..0000000 --- a/aine/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; -import { viteSingleFile } from "vite-plugin-singlefile"; - -export default defineConfig(({ command }) => ({ - plugins: [svelte(), ...(command === "build" ? [viteSingleFile()] : [])], - root: "src", - build: { - outDir: "../dist", - emptyOutDir: true, - }, - server: { - port: 5492, - proxy: { - "/api": "http://localhost:5491", - }, - }, -})); diff --git a/extensions/aine/index.ts b/extensions/aine/index.ts deleted file mode 100644 index 77bccb7..0000000 --- a/extensions/aine/index.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Aine – Pi extension (thin wrapper around standalone aine CLI). - * - * Commands: - * /aine → open diff viewer - * /aine main → compare current branch against main - * /aine-review → AI reviews current diff and leaves comments - * /aine-resolve → resolve open comments by making code changes - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { spawn, execFileSync, type ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const STANDALONE_CLI = join(__dirname, "..", "..", "aine", "cli.ts"); -const PORT = 5491; - -let aineProcess: ChildProcess | null = null; - -function stopAine() { - if (aineProcess) { - try { aineProcess.kill(); } catch {} - aineProcess = null; - } -} - -function aineCli(args: string[]): string[] { - if (existsSync(STANDALONE_CLI)) { - return ["bun", STANDALONE_CLI, ...args]; - } - return ["aine", ...args]; -} - -function aineExec(args: string[], cwd: string): string { - const [cmd, ...cmdArgs] = aineCli(args); - return execFileSync(cmd, cmdArgs, { - cwd, - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - timeout: 10000, - }).trim(); -} - -function startAine(args: string[], cwd: string): Promise { - stopAine(); - - return new Promise((resolve, reject) => { - const [cmd, ...cmdArgs] = aineCli(args); - const proc = spawn(cmd, cmdArgs, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - }); - - aineProcess = proc; - let started = false; - - proc.stdout?.on("data", (data: Buffer) => { - const text = data.toString(); - if (!started && text.includes("aine →")) { - started = true; - resolve(); - } - }); - - proc.stderr?.on("data", (data: Buffer) => { - if (!started) reject(new Error(data.toString().trim())); - }); - - proc.on("error", (err) => { - if (!started) reject(err); - }); - - proc.on("exit", () => { aineProcess = null; }); - - setTimeout(() => { - if (!started) reject(new Error("Timeout starting aine")); - }, 10000); - }); -} - -/** Ensure an aine session is running, start one if needed. */ -async function ensureSession(cwd: string): Promise { - try { - const out = aineExec(["session", "list", "--json"], cwd); - const sessions = JSON.parse(out); - if (sessions.length > 0) return true; - } catch {} - - // No session running — start one - try { - await startAine(["--port", String(PORT), "--no-open"], cwd); - return true; - } catch { - return false; - } -} - -export default function (pi: ExtensionAPI) { - // ── /aine – open diff viewer ────────────────────────────────── - - pi.registerCommand("aine", { - description: "Open diff viewer (/aine [ref] [ref2])", - handler: async (args, ctx) => { - const cliArgs: string[] = ["--port", String(PORT)]; - - if (args?.trim()) { - const refs = args.trim().split(/\s+/).filter(Boolean); - cliArgs.push(...refs); - } - - try { - await startAine(cliArgs, ctx.cwd); - ctx.ui.notify(`aine → http://localhost:${PORT}`, "info"); - } catch (err) { - ctx.ui.notify("Failed to start aine: " + err, "error"); - } - }, - }); - - // ── /aine-review – AI reviews diff and leaves comments ──────── - - pi.registerCommand("aine-review", { - description: "AI reviews current diff and leaves inline comments", - handler: async (args, ctx) => { - if (!await ensureSession(ctx.cwd)) { - ctx.ui.notify("Failed to start aine session", "error"); - return; - } - - // Get the context - let context: any; - try { - const out = aineExec(["session", "context", "--repo", ".", "--json"], ctx.cwd); - context = JSON.parse(out); - } catch (e) { - ctx.ui.notify("Failed to get aine session context: " + e, "error"); - return; - } - - // Build review prompt with file list and focus area - const focus = args?.trim() || ""; - const fileList = context.files - .map((f: any) => { - const stat = f.status === "added" ? "+" : f.status === "deleted" ? "-" : "~"; - return ` ${stat} ${f.path} (+${f.additions} -${f.deletions})`; - }) - .join("\n"); - - const reviewPrompt = `Review the following code changes and leave inline comments using \`aine session comment add\`. - -Repository: ${context.repoName} (${context.branch}) -Diff: ${context.description} -Files: -${fileList} -${focus ? `\nFocus: ${focus}` : ""} - -For each issue found, run: - aine session comment add --repo . --file --line --summary "" --severity - -Guidelines: -- Use severity levels: must-fix (bugs, security), suggestion (improvements), nit (style/minor) -- Be specific: reference the actual code, explain why it's a problem and how to fix it -- Don't comment on everything — focus on what matters -- After reviewing, summarize your findings - -Read each changed file to understand the full context before commenting.`; - - // Send as assistant message for the agent to execute - ctx.appendMessage("user", reviewPrompt); - }, - }); - - // ── /aine-resolve – resolve open comments ───────────────────── - - pi.registerCommand("aine-resolve", { - description: "Read open comments and make code changes to resolve them", - handler: async (args, ctx) => { - // Get comments - let commentsJson: string; - try { - commentsJson = aineExec(["session", "comment", "list", "--repo", ".", "--json"], ctx.cwd); - } catch (e) { - ctx.ui.notify("Failed to get comments: " + e, "error"); - return; - } - - let allComments: any[]; - try { - allComments = JSON.parse(commentsJson); - } catch { - ctx.ui.notify("No active aine session", "error"); - return; - } - - // Filter: only unresolved, or specific ID - const targetId = args?.trim(); - const toResolve = targetId - ? allComments.filter(c => c.id === targetId) - : allComments.filter(c => !c.resolved); - - if (toResolve.length === 0) { - ctx.ui.notify("No comments to resolve", "info"); - return; - } - - const commentList = toResolve - .map((c: any) => { - const sev = c.severity ? ` [${c.severity}]` : ""; - return `- ${c.id} ${c.file}:${c.line}${sev} — ${c.summary}`; - }) - .join("\n"); - - const resolvePrompt = `Resolve the following code review comments by making the necessary code changes. - -Comments to resolve: -${commentList} - -For each comment: -1. Read the file to understand the context -2. Make the code change to address the feedback -3. After fixing, mark as resolved: \`aine session comment resolve --repo . \` - -Work through them one by one. If a comment is unclear or you disagree, explain why instead of blindly applying it.`; - - ctx.appendMessage("user", resolvePrompt); - }, - }); - - pi.on("session_shutdown", stopAine); -} diff --git a/extensions/cmux/cmux-client.ts b/extensions/cmux/cmux-client.ts index c54957e..3d3f610 100644 --- a/extensions/cmux/cmux-client.ts +++ b/extensions/cmux/cmux-client.ts @@ -89,8 +89,54 @@ export class CmuxClient { } } + /** Default timeouts by method category (ms). */ + private static readonly TIMEOUT_FAST = 5_000; // metadata queries + private static readonly TIMEOUT_NORMAL = 15_000; // interactions, snapshots + private static readonly TIMEOUT_NAV = 30_000; // navigation, page loads + + /** Determine timeout for a given method. */ + private timeoutFor(method: string): number { + // Fast: pure metadata, no page interaction needed + if (method.startsWith("browser.tab.") || + method === "browser.url.get" || + method.startsWith("browser.cookies.") || + method.startsWith("browser.storage.") || + method === "browser.console.list" || + method === "browser.errors.list" || + method === "browser.network.requests" || + method.startsWith("browser.is.") || + method.startsWith("browser.find.") || + method === "browser.get.title") { + return CmuxClient.TIMEOUT_FAST; + } + // Slow: page loads + if (method === "browser.navigate" || + method === "browser.open_split" || + method === "browser.reload" || + method === "browser.back" || + method === "browser.forward" || + method === "browser.wait" || + method === "browser.download.wait") { + return CmuxClient.TIMEOUT_NAV; + } + // Everything else: normal + return CmuxClient.TIMEOUT_NORMAL; + } + + /** Consecutive timeout counter for circuit-breaker logic. */ + private consecutiveTimeouts = 0; + private static readonly CIRCUIT_BREAKER_THRESHOLD = 3; + /** Send a v2 JSON request. Returns the result or null on failure. */ - async request(method: string, params?: Record): Promise { + async request(method: string, params?: Record, timeoutOverride?: number): Promise { + // Circuit breaker: if we've had too many consecutive timeouts, fail fast + // (but allow tab_list / url.get through — they're diagnostic) + if (this.consecutiveTimeouts >= CmuxClient.CIRCUIT_BREAKER_THRESHOLD && + !method.startsWith("browser.tab.") && method !== "browser.url.get") { + if (this.verbose) console.error("[pi-cmux] circuit breaker open, skipping:", method); + return null; + } + // Auto-reconnect if (!this.isConnected()) { const ok = await this.connect(); @@ -99,20 +145,23 @@ export class CmuxClient { const id = crypto.randomUUID(); const payload = JSON.stringify({ id, method, params: params ?? {} }); + const timeout = timeoutOverride ?? this.timeoutFor(method); - if (this.verbose) console.error("[pi-cmux] ->", payload); + if (this.verbose) console.error("[pi-cmux] ->", payload, `(timeout: ${timeout}ms)`); return new Promise((resolve) => { const timer = setTimeout(() => { this.pending.delete(id); - if (this.verbose) console.error("[pi-cmux] request timeout:", method); + this.consecutiveTimeouts++; + if (this.verbose) console.error("[pi-cmux] request timeout:", method, `(consecutive: ${this.consecutiveTimeouts})`); resolve(null); - }, 15_000); + }, timeout); this.pending.set(id, { resolve: (value) => { clearTimeout(timer); this.pending.delete(id); + this.consecutiveTimeouts = 0; // reset circuit breaker on success resolve(value); }, reject: (reason) => { @@ -173,6 +222,25 @@ export class CmuxClient { }); } + /** + * Send multiple requests in parallel over the same socket. + * All requests are written immediately (pipelined) and resolved concurrently. + * Returns results in the same order as the input. + */ + async parallel(requests: Array<{ method: string; params?: Record }>): Promise> { + return Promise.all(requests.map((r) => this.request(r.method, r.params))); + } + + /** Reset the circuit breaker (e.g. after recovery via tab_new). */ + resetCircuitBreaker(): void { + this.consecutiveTimeouts = 0; + } + + /** True if the circuit breaker is currently open (too many consecutive timeouts). */ + get circuitBreakerOpen(): boolean { + return this.consecutiveTimeouts >= CmuxClient.CIRCUIT_BREAKER_THRESHOLD; + } + /** Close the connection. */ close(): void { if (this.socket) { diff --git a/extensions/cmux/tools.ts b/extensions/cmux/tools.ts index fa8014b..e623471 100644 --- a/extensions/cmux/tools.ts +++ b/extensions/cmux/tools.ts @@ -21,10 +21,11 @@ function errorResult(text: string): ToolResult { } /** Format a cmux response as a proper ToolResult. */ -function formatResult(result: any): ToolResult { +function formatResult(result: any, context?: string): ToolResult { let text: string; if (result === null || result === undefined) { - text = "cmux did not respond (socket unavailable or timed out)"; + const ctx = context ? ` [${context}]` : ""; + text = `cmux did not respond (socket unavailable or timed out)${ctx}`; } else if (typeof result === "string") { text = result; } else { @@ -36,142 +37,509 @@ function formatResult(result: any): ToolResult { return { content: [{ type: "text", text }], details: {} }; } +/** Check if cmux returned a null/timeout result. */ +function isTimeout(result: any): boolean { + return result === null || result === undefined; +} + export function wireTools(pi: ExtensionAPI, client: CmuxClient): void { // --- cmux_browser --- pi.registerTool({ name: "cmux_browser", label: "Browser", description: [ - "Control the cmux in-app browser. Open URLs in a split pane, navigate, take accessibility snapshots, click elements, fill forms, evaluate JS, and more.", - "The browser runs inside the cmux terminal app — no separate browser needed.", + "Control cmux in-app browser. Runs inside the terminal — no separate browser needed.", "", - "Actions:", - "- open: Open a URL in a new browser split pane", - "- navigate: Navigate the current browser to a URL", - "- snapshot: Get the accessibility tree of the current page (LLM-readable)", - "- click: Click an element by CSS selector", - "- fill: Fill a form field by CSS selector with text", - "- eval: Evaluate JavaScript in the page and return the result", - "- screenshot: Take a screenshot of the page", - "- get_text: Get the text content of an element by CSS selector", - "- get_url: Get the current page URL", - "- wait: Wait for an element to appear or disappear (set hidden=true to wait for disappearance)", - "- back: Go back in browser history", - "- forward: Go forward in browser history", - "- reload: Reload the current page", - "- press: Send a keypress to the page (e.g. 'Enter', 'Tab', 'Escape')", - "- scroll: Scroll an element (specify selector, dx, dy)", - "- find_role: Find elements by ARIA role", - "- is_visible: Check if an element is visible", + "Navigation: open (split pane), navigate, back, forward, reload", + "Snapshot: snapshot (accessibility tree — use compact/selector/max_depth to control size)", + "Interact: click, dblclick, fill (replace value), type (keystroke-by-keystroke), select, press, hover, check, uncheck, scroll, scroll_into_view, highlight", + "Query: get (text/html/value/attr/count/title/styles), is (visible/enabled/checked), screenshot, wait", + "Find: find (by role/text/label/placeholder/testid/title)", + "Dialogs: dialog_accept, dialog_dismiss", + "Tabs: tab_list, tab_new, tab_switch, tab_close", + "Cookies: cookies_get, cookies_set, cookies_clear", + "Storage: storage_get, storage_set, storage_clear (localStorage/sessionStorage)", + "Network: network (inspect requests)", + "Inject: addstyle (inject CSS), addscript (inject JS)", + "State: console, errors, eval, download", + "Frames: frame (select iframe), frame_main (back to main)", + "", + "Safari cookies are automatically available via cmux browser profile import.", + "Most interaction actions support snapshot_after=true to return the updated accessibility tree in one round-trip.", ].join("\n"), parameters: Type.Object({ action: StringEnum([ - "open", "navigate", "snapshot", "click", "fill", - "eval", "screenshot", "get_text", "get_url", "wait", - "back", "forward", "reload", "press", "scroll", - "find_role", "is_visible", + // Navigation + "open", "navigate", "back", "forward", "reload", + // Snapshot & query + "snapshot", "screenshot", "get", "wait", + // Interaction + "click", "dblclick", "fill", "type", "select", "press", + "hover", "check", "uncheck", "scroll", "highlight", + // Find + "find", + // Dialogs + "dialog_accept", "dialog_dismiss", + // Tabs + "tab_list", "tab_new", "tab_switch", "tab_close", + // Query + "is", "scroll_into_view", + // Cookies & storage + "cookies_get", "cookies_set", "cookies_clear", + "storage_get", "storage_set", "storage_clear", + // Network + "network", + // Inject + "addstyle", "addscript", + // State & debug + "console", "errors", "eval", "download", + // Frames + "frame", "frame_main", ] as const), - url: Type.Optional(Type.String({ description: "URL for open/navigate actions" })), - selector: Type.Optional(Type.String({ description: "CSS selector for click/fill/get_text/wait/scroll/is_visible" })), - text: Type.Optional(Type.String({ description: "Text for fill action, or key name for press action" })), - code: Type.Optional(Type.String({ description: "JavaScript code for eval action" })), - role: Type.Optional(Type.String({ description: "ARIA role for find_role action" })), + url: Type.Optional(Type.String({ description: "URL for open/navigate/tab_new" })), + selector: Type.Optional(Type.String({ description: "CSS selector for interactions, get, wait, scroll, highlight, frame" })), + text: Type.Optional(Type.String({ description: "Text for fill/type/select/press/dialog_accept, or key name for press" })), + code: Type.Optional(Type.String({ description: "JavaScript code for eval/addscript action" })), + css: Type.Optional(Type.String({ description: "CSS code for addstyle action" })), + // Is action: what to check + check_type: Type.Optional(StringEnum( + ["visible", "enabled", "checked"] as const, + { description: "For is action: what to check" }, + )), + // Storage + storage_type: Type.Optional(StringEnum( + ["local", "session"] as const, + { description: "For storage actions: localStorage or sessionStorage (default: local)" }, + )), + key: Type.Optional(Type.String({ description: "For storage_get/set, cookies_set: key/name" })), + value: Type.Optional(Type.String({ description: "For storage_set, cookies_set: value" })), + // Cookies set + domain: Type.Optional(Type.String({ description: "For cookies_set: cookie domain" })), + path: Type.Optional(Type.String({ description: "For cookies_set/download: cookie path or download path" })), + secure: Type.Optional(Type.Boolean({ description: "For cookies_set: secure flag" })), + expires: Type.Optional(Type.Number({ description: "For cookies_set: expiry as unix timestamp" })), + // Get action: what property to retrieve + property: Type.Optional(StringEnum( + ["text", "html", "value", "attr", "count", "title", "styles", "url"] as const, + { description: "For get action: which property to retrieve" }, + )), + attr_name: Type.Optional(Type.String({ description: "For get action with property=attr: attribute name" })), + // Find action + find_by: Type.Optional(StringEnum( + ["role", "text", "label", "placeholder", "testid", "title"] as const, + { description: "For find action: what to search by" }, + )), + find_value: Type.Optional(Type.String({ description: "For find action: value to search for" })), + // Snapshot options + compact: Type.Optional(Type.Boolean({ description: "For snapshot: compact output (less verbose)" })), + interactive: Type.Optional(Type.Boolean({ description: "For snapshot: only interactive elements" })), + max_depth: Type.Optional(Type.Number({ description: "For snapshot: max tree depth" })), + // Scroll + dx: Type.Optional(Type.Number({ description: "Horizontal scroll amount" })), + dy: Type.Optional(Type.Number({ description: "Vertical scroll amount" })), + // Wait + hidden: Type.Optional(Type.Boolean({ description: "For wait: true to wait until element disappears" })), + // Tabs + tab_index: Type.Optional(Type.Number({ description: "For tab_switch/tab_close: tab index" })), + // Cross-cutting + snapshot_after: Type.Optional(Type.Boolean({ description: "Return accessibility snapshot after the action (saves a round-trip)" })), surface_id: Type.Optional(Type.String({ description: "Target browser surface ID (uses current if omitted)" })), - hidden: Type.Optional(Type.Boolean({ description: "For wait action: true to wait until element disappears" })), - dx: Type.Optional(Type.Number({ description: "Horizontal scroll amount for scroll action" })), - dy: Type.Optional(Type.Number({ description: "Vertical scroll amount for scroll action" })), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const surfaceParams = params.surface_id ? { surface_id: params.surface_id } : {}; + const sp = params.surface_id ? { surface_id: params.surface_id } : {}; + const snapshotAfter = params.snapshot_after ?? false; + + /** Run a browser action and optionally append a snapshot. */ + async function act(method: string, reqParams: Record, fallback: string): Promise { + const result = await client.request(method, { ...reqParams, ...sp }); + if (isTimeout(result)) { + const hint = client.circuitBreakerOpen + ? " Circuit breaker is OPEN — multiple requests have timed out. The browser is likely stuck. Use tab_list to diagnose, then tab_new or tab_close to recover." + : ""; + return errorResult(`${method} failed (cmux timeout).${hint}`); + } + if (!snapshotAfter) return formatResult(result ?? fallback); + // Snapshot after action — the action already completed, so DOM should reflect the change. + // Fire snapshot + url.get in parallel for richer context. + const [snap, urlResult] = await client.parallel([ + { method: "browser.snapshot", params: sp }, + { method: "browser.url.get", params: sp }, + ]); + const url = typeof urlResult === "string" ? urlResult : urlResult?.url; + return formatResult({ action_result: result ?? fallback, snapshot: snap, ...(url ? { url } : {}) }); + } switch (params.action) { + // --- Navigation --- case "open": { - if (!params.url) return errorResult("Error: url is required for open action"); - const result = await client.request("browser.open_split", { url: params.url, ...surfaceParams }); - return formatResult(result ?? "Browser split opened"); + if (!params.url) return errorResult("url is required for open"); + const result = await client.request("browser.open_split", { url: params.url, ...sp }); + if (result === null || result === undefined) { + return errorResult( + `Failed to open browser split for ${params.url} (cmux timeout). ` + + `Check that cmux is running and the socket is available.` + ); + } + if (!snapshotAfter) return formatResult(result ?? "Browser split opened"); + // For open, the surface_id comes from the result + const openSurfaceId = result?.surface_id; + const snapSp = openSurfaceId ? { surface_id: openSurfaceId } : sp; + const snap = await client.request("browser.snapshot", snapSp); + return formatResult({ action_result: result ?? "Browser split opened", snapshot: snap }); } case "navigate": { - if (!params.url) return errorResult("Error: url is required for navigate action"); - const result = await client.request("browser.navigate", { url: params.url, ...surfaceParams }); - return formatResult(result ?? "Navigated"); + if (!params.url) return errorResult("url is required for navigate"); + const navResult = await client.request("browser.navigate", { url: params.url, ...sp }); + + // If navigate timed out, auto-fallback: open in a new tab instead + if (isTimeout(navResult)) { + const newTab = await client.request("browser.tab.new", { url: params.url, ...sp }); + if (isTimeout(newTab)) { + return errorResult( + `Navigate to ${params.url} failed and tab_new fallback also timed out. ` + + `The browser may be completely unresponsive. Try closing and reopening it.` + ); + } + client.resetCircuitBreaker(); + const fallbackMsg = `Navigate timed out — opened ${params.url} in a new tab instead.`; + if (!snapshotAfter) return formatResult({ ...newTab, note: fallbackMsg }); + // snapshot in parallel with nothing — just fire it on the new tab + const fallbackSurfaceId = newTab?.surface_id; + const fallbackSp = fallbackSurfaceId ? { surface_id: fallbackSurfaceId } : sp; + const fallbackSnap = await client.request("browser.snapshot", fallbackSp); + return formatResult({ action_result: fallbackMsg, new_tab: newTab, snapshot: fallbackSnap }); + } + + // Fire url verification and optional snapshot in parallel + // Both are read-only after navigate completes — no ordering dependency + const pendingUrl = client.request("browser.url.get", sp); + const pendingSnap = snapshotAfter ? client.request("browser.snapshot", sp) : null; + const [urlAfter, navSnap] = await Promise.all([pendingUrl, pendingSnap]); + + const actualUrl = typeof urlAfter === "string" ? urlAfter : urlAfter?.url; + if (actualUrl && params.url && !actualUrl.startsWith(params.url.replace(/\/$/, ""))) { + // URL didn't change — try tab_new as fallback + const recoveryTab = await client.request("browser.tab.new", { url: params.url, ...sp }); + if (!isTimeout(recoveryTab)) { + client.resetCircuitBreaker(); + const recoveryMsg = `Navigate to ${params.url} didn't take effect (still on ${actualUrl}). Opened in a new tab instead.`; + if (!snapshotAfter) return formatResult({ ...recoveryTab, note: recoveryMsg }); + const recoverySurfaceId = recoveryTab?.surface_id; + const recoverySp = recoverySurfaceId ? { surface_id: recoverySurfaceId } : sp; + const recoverySnap = await client.request("browser.snapshot", recoverySp); + return formatResult({ action_result: recoveryMsg, new_tab: recoveryTab, snapshot: recoverySnap }); + } + return errorResult( + `Navigate to ${params.url} failed — browser is still on ${actualUrl} and tab_new also failed.` + ); + } + + if (!snapshotAfter) return formatResult(navResult ?? "Navigated"); + return formatResult({ action_result: navResult ?? "Navigated", snapshot: navSnap }); } + case "back": return act("browser.back", {}, "Navigated back"); + case "forward": return act("browser.forward", {}, "Navigated forward"); + case "reload": return act("browser.reload", {}, "Reloaded"); + + // --- Snapshot & query --- case "snapshot": { - const result = await client.request("browser.snapshot", surfaceParams); + const snapParams: Record = { ...sp }; + if (params.compact) snapParams.compact = true; + if (params.interactive) snapParams.interactive = true; + if (params.max_depth !== undefined) snapParams.max_depth = params.max_depth; + if (params.selector) snapParams.selector = params.selector; + const result = await client.request("browser.snapshot", snapParams); return formatResult(result); } + case "screenshot": { + // If snapshot_after requested, fire both in parallel — screenshot and snapshot are independent reads + if (snapshotAfter) { + const [ssResult, snapResult] = await client.parallel([ + { method: "browser.screenshot", params: sp }, + { method: "browser.snapshot", params: sp }, + ]); + if (isTimeout(ssResult)) { + return errorResult( + "Screenshot failed (cmux timeout). The browser may be on an error page or unresponsive. " + + "Try tab_list to check the current URL, or open a new tab with tab_new." + ); + } + return formatResult({ screenshot: ssResult, snapshot: snapResult }); + } + const result = await client.request("browser.screenshot", sp); + if (isTimeout(result)) { + return errorResult( + "Screenshot failed (cmux timeout). The browser may be on an error page or unresponsive. " + + "Try tab_list to check the current URL, or open a new tab with tab_new." + ); + } + return formatResult(result); + } + case "get": { + const prop = params.property; + if (!prop) return errorResult("property is required for get (text/html/value/attr/count/title/styles/url)"); + if (prop === "url") { + const result = await client.request("browser.url.get", sp); + if (result === null || result === undefined) { + return errorResult( + "URL fetch failed (cmux timeout). The browser may be unresponsive. " + + "Try tab_list which also shows URLs." + ); + } + return formatResult(result); + } + if (prop === "title") { + const result = await client.request("browser.get.title", sp); + return formatResult(result); + } + if (!params.selector) return errorResult(`selector is required for get ${prop}`); + const getParams: Record = { selector: params.selector, ...sp }; + if (prop === "attr") { + if (!params.attr_name) return errorResult("attr_name is required for get attr"); + getParams.name = params.attr_name; + } + const result = await client.request(`browser.get.${prop}`, getParams); + return formatResult(result); + } + case "wait": { + if (!params.selector) return errorResult("selector is required for wait"); + const waitParams: Record = { selector: params.selector, ...sp }; + if (params.hidden) waitParams.hidden = true; + const result = await client.request("browser.wait", waitParams); + if (isTimeout(result)) return errorResult("wait timed out — element not found or page unresponsive."); + if (!snapshotAfter) return formatResult(result ?? "Element found"); + const snap = await client.request("browser.snapshot", sp); + return formatResult({ action_result: result ?? "Element found", snapshot: snap }); + } + + // --- Interaction --- case "click": { - if (!params.selector) return errorResult("Error: selector is required for click action"); - const result = await client.request("browser.click", { selector: params.selector, ...surfaceParams }); - return formatResult(result ?? "Clicked"); + if (!params.selector) return errorResult("selector is required for click"); + return act("browser.click", { selector: params.selector }, "Clicked"); + } + case "dblclick": { + if (!params.selector) return errorResult("selector is required for dblclick"); + return act("browser.dblclick", { selector: params.selector }, "Double-clicked"); } case "fill": { - if (!params.selector) return errorResult("Error: selector is required for fill action"); - if (params.text === undefined) return errorResult("Error: text is required for fill action"); - const result = await client.request("browser.fill", { selector: params.selector, text: params.text, ...surfaceParams }); - return formatResult(result ?? "Filled"); + if (!params.selector) return errorResult("selector is required for fill"); + return act("browser.fill", { selector: params.selector, text: params.text ?? "" }, "Filled"); } - case "eval": { - if (!params.code) return errorResult("Error: code is required for eval action"); - const result = await client.request("browser.eval", { code: params.code, ...surfaceParams }); + case "type": { + if (!params.selector) return errorResult("selector is required for type"); + if (!params.text) return errorResult("text is required for type"); + return act("browser.type", { selector: params.selector, text: params.text }, "Typed"); + } + case "select": { + if (!params.selector) return errorResult("selector is required for select"); + if (!params.text) return errorResult("text (option value) is required for select"); + return act("browser.select", { selector: params.selector, value: params.text }, "Selected"); + } + case "press": { + if (!params.text) return errorResult("text (key name) is required for press"); + return act("browser.press", { key: params.text }, "Key pressed"); + } + case "hover": { + if (!params.selector) return errorResult("selector is required for hover"); + return act("browser.hover", { selector: params.selector }, "Hovered"); + } + case "check": { + if (!params.selector) return errorResult("selector is required for check"); + return act("browser.check", { selector: params.selector }, "Checked"); + } + case "uncheck": { + if (!params.selector) return errorResult("selector is required for uncheck"); + return act("browser.uncheck", { selector: params.selector }, "Unchecked"); + } + case "scroll": { + const scrollParams: Record = {}; + if (params.selector) scrollParams.selector = params.selector; + scrollParams.dx = params.dx ?? 0; + scrollParams.dy = params.dy ?? 0; + return act("browser.scroll", scrollParams, "Scrolled"); + } + case "scroll_into_view": { + if (!params.selector) return errorResult("selector is required for scroll_into_view"); + return act("browser.scroll_into_view", { selector: params.selector }, "Scrolled into view"); + } + case "highlight": { + if (!params.selector) return errorResult("selector is required for highlight"); + const result = await client.request("browser.highlight", { selector: params.selector, ...sp }); + return formatResult(result ?? "Highlighted"); + } + + // --- Query: is --- + case "is": { + if (!params.selector) return errorResult("selector is required for is"); + const checkType = params.check_type ?? "visible"; + const result = await client.request(`browser.is.${checkType}`, { selector: params.selector, ...sp }); return formatResult(result); } - case "screenshot": { - const result = await client.request("browser.screenshot", surfaceParams); + + // --- Find --- + case "find": { + if (!params.find_by) return errorResult("find_by is required (role/text/label/placeholder/testid/title)"); + if (!params.find_value) return errorResult("find_value is required for find"); + const result = await client.request(`browser.find.${params.find_by}`, { + [params.find_by]: params.find_value, ...sp, + }); return formatResult(result); } - case "get_text": { - if (!params.selector) return errorResult("Error: selector is required for get_text action"); - const result = await client.request("browser.get_text", { selector: params.selector, ...surfaceParams }); + + // --- Dialogs --- + case "dialog_accept": { + const dialogParams: Record = { ...sp }; + if (params.text) dialogParams.text = params.text; + const result = await client.request("browser.dialog.accept", dialogParams); + return formatResult(result ?? "Dialog accepted"); + } + case "dialog_dismiss": { + const result = await client.request("browser.dialog.dismiss", sp); + return formatResult(result ?? "Dialog dismissed"); + } + + // --- Tabs --- + case "tab_list": { + const result = await client.request("browser.tab.list", sp); + // Enrich with circuit breaker status so agent knows if browser is stuck + if (result && typeof result === "object" && client.circuitBreakerOpen) { + return formatResult({ + ...result, + _warning: "Circuit breaker is OPEN — multiple recent requests timed out. The browser may be stuck on an error page. Use tab_new to open a fresh tab, or tab_close to close stuck tabs.", + }); + } return formatResult(result); } - case "get_url": { - const result = await client.request("browser.url.get", surfaceParams); + case "tab_new": { + const tabParams: Record = { ...sp }; + if (params.url) tabParams.url = params.url; + const result = await client.request("browser.tab.new", tabParams); return formatResult(result); } - case "wait": { - if (!params.selector) return errorResult("Error: selector is required for wait action"); - const waitParams: Record = { selector: params.selector, ...surfaceParams }; - if (params.hidden) waitParams.hidden = true; - const result = await client.request("browser.wait", waitParams); - return formatResult(result ?? "Element found"); + case "tab_switch": { + if (params.tab_index === undefined) return errorResult("tab_index is required for tab_switch"); + const result = await client.request("browser.tab.switch", { index: params.tab_index, ...sp }); + return formatResult(result ?? "Tab switched"); } - case "back": { - const result = await client.request("browser.back", surfaceParams); - return formatResult(result ?? "Navigated back"); + case "tab_close": { + const closeParams: Record = { ...sp }; + if (params.tab_index !== undefined) closeParams.index = params.tab_index; + const result = await client.request("browser.tab.close", closeParams); + return formatResult(result ?? "Tab closed"); } - case "forward": { - const result = await client.request("browser.forward", surfaceParams); - return formatResult(result ?? "Navigated forward"); + + // --- Cookies --- + case "cookies_get": { + const cookieParams: Record = { ...sp }; + if (params.key) cookieParams.name = params.key; + if (params.domain) cookieParams.domain = params.domain; + const result = await client.request("browser.cookies.get", cookieParams); + return formatResult(result); } - case "reload": { - const result = await client.request("browser.reload", surfaceParams); - return formatResult(result ?? "Reloaded"); + case "cookies_set": { + if (!params.key) return errorResult("key (cookie name) is required for cookies_set"); + if (params.value === undefined) return errorResult("value is required for cookies_set"); + const cookieParams: Record = { name: params.key, value: params.value, ...sp }; + if (params.url) cookieParams.url = params.url; + if (params.domain) cookieParams.domain = params.domain; + if (params.path) cookieParams.path = params.path; + if (params.secure) cookieParams.secure = true; + if (params.expires !== undefined) cookieParams.expires = params.expires; + const result = await client.request("browser.cookies.set", cookieParams); + return formatResult(result ?? "Cookie set"); } - case "press": { - if (!params.text) return errorResult("Error: text (key name) is required for press action"); - const result = await client.request("browser.press", { key: params.text, ...surfaceParams }); - return formatResult(result ?? "Key pressed"); + case "cookies_clear": { + const clearParams: Record = { ...sp }; + if (params.domain) clearParams.domain = params.domain; + const result = await client.request("browser.cookies.clear", clearParams); + return formatResult(result ?? "Cookies cleared"); } - case "scroll": { - if (!params.selector) return errorResult("Error: selector is required for scroll action"); - const result = await client.request("browser.scroll", { - selector: params.selector, - dx: params.dx ?? 0, - dy: params.dy ?? 0, - ...surfaceParams, + + // --- Storage --- + case "storage_get": { + const storageType = params.storage_type ?? "local"; + const storageParams: Record = { type: storageType, ...sp }; + if (params.key) storageParams.key = params.key; + const result = await client.request("browser.storage.get", storageParams); + return formatResult(result); + } + case "storage_set": { + if (!params.key) return errorResult("key is required for storage_set"); + if (params.value === undefined) return errorResult("value is required for storage_set"); + const storageType = params.storage_type ?? "local"; + const result = await client.request("browser.storage.set", { + type: storageType, key: params.key, value: params.value, ...sp, }); - return formatResult(result ?? "Scrolled"); + return formatResult(result ?? "Storage value set"); } - case "find_role": { - if (!params.role) return errorResult("Error: role is required for find_role action"); - const result = await client.request("browser.find.role", { role: params.role, ...surfaceParams }); + case "storage_clear": { + const storageType = params.storage_type ?? "local"; + const result = await client.request("browser.storage.clear", { type: storageType, ...sp }); + return formatResult(result ?? "Storage cleared"); + } + + // --- Network --- + case "network": { + const result = await client.request("browser.network.requests", sp); return formatResult(result); } - case "is_visible": { - if (!params.selector) return errorResult("Error: selector is required for is_visible action"); - const result = await client.request("browser.is_visible", { selector: params.selector, ...surfaceParams }); + + // --- Inject --- + case "addstyle": { + if (!params.css) return errorResult("css is required for addstyle"); + const result = await client.request("browser.addstyle", { css: params.css, ...sp }); + return formatResult(result ?? "Style injected"); + } + case "addscript": { + if (!params.code) return errorResult("code is required for addscript"); + const result = await client.request("browser.addscript", { script: params.code, ...sp }); + return formatResult(result ?? "Script injected"); + } + + // --- State & debug --- + case "console": { + const result = await client.request("browser.console.list", sp); + if (result === null || result === undefined) { + return errorResult( + "Console fetch failed (cmux timeout). The browser page may be unresponsive or on an error page. " + + "Try tab_list to check browser state." + ); + } + return formatResult(result); + } + case "errors": { + const result = await client.request("browser.errors.list", sp); + if (result === null || result === undefined) { + return errorResult( + "Errors fetch failed (cmux timeout). The browser page may be unresponsive or on an error page. " + + "Try tab_list to check browser state." + ); + } return formatResult(result); } + case "eval": { + if (!params.code) return errorResult("code is required for eval"); + const result = await client.request("browser.eval", { code: params.code, ...sp }); + return formatResult(result); + } + case "download": { + const dlParams: Record = { ...sp }; + if (params.path) dlParams.path = params.path; + const result = await client.request("browser.download.wait", dlParams); + return formatResult(result); + } + + // --- Frames --- + case "frame": { + if (!params.selector) return errorResult("selector is required for frame"); + const result = await client.request("browser.frame.select", { selector: params.selector, ...sp }); + return formatResult(result ?? "Frame selected"); + } + case "frame_main": { + const result = await client.request("browser.frame.main", sp); + return formatResult(result ?? "Switched to main frame"); + } + default: return errorResult(`Unknown action: ${params.action}`); } diff --git a/extensions/custom-footer/renderers.ts b/extensions/custom-footer/renderers.ts index e7e94db..9be6d1a 100644 --- a/extensions/custom-footer/renderers.ts +++ b/extensions/custom-footer/renderers.ts @@ -7,6 +7,7 @@ import type { PermissionMode } from "../permissions/permissions.js"; import { visibleWidth } from "@mariozechner/pi-tui"; +import { THINKING_ROLES } from "../shared/thinking-colors.js"; type ThemeFg = { fg: (role: any, text: string) => string; bold: (text: string) => string; inverse: (text: string) => string }; @@ -14,21 +15,12 @@ type ThemeFg = { fg: (role: any, text: string) => string; bold: (text: string) = /** Theme roles for permission mode pills. */ const MODE_ROLES: Record = { - yolo: "error", // red - safe: "success", // green - plan: "accent", // mauve - "read-only": "mdHeading", // blue + yolo: "error", // red + safe: "success", // green + "read-only": "mdHeading", // blue }; -/** Theme roles for thinking-level indicators. */ -const THINKING_ROLES: Record = { - off: "dim", - minimal: "dim", - low: "success", // green - medium: "warning", // yellow - high: "bashMode", // peach - xhigh: "error", // red -}; + // ── Tokens ───────────────────────────────────────────────────────────── diff --git a/extensions/handoff/index.ts b/extensions/handoff/index.ts new file mode 100644 index 0000000..efc583b --- /dev/null +++ b/extensions/handoff/index.ts @@ -0,0 +1,732 @@ +/** + * Handoff extension — transfer context to a new pi session in a cmux tab. + * + * Uses pi-vcc's algorithmic compaction (no LLM calls) to generate a + * context summary, plus algorithmic extraction of git state, working files, + * and language detection. Opens a new cmux tab with a fresh pi session + * that has the summary + goal as its initial prompt. + * + * Includes current tasks from pi-tasks in the handoff prompt. + * + * Usage: + * /handoff now implement this for teams as well + * /handoff check other places that need this fix + */ + +import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent"; +import { convertToLlm } from "@mariozechner/pi-coding-agent"; +import { matchesKey, Key } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; +import { join, dirname, resolve } from "node:path"; +import { execSync, execFileSync } from "node:child_process"; +import { OverlayFrame } from "../shared/overlay.js"; +import { THINKING_ROLES } from "../shared/thinking-colors.js"; + +import { normalize } from "@sting8k/pi-vcc/src/core/normalize"; +import { filterNoise } from "@sting8k/pi-vcc/src/core/filter-noise"; +import { buildSections } from "@sting8k/pi-vcc/src/core/build-sections"; +import { redact } from "@sting8k/pi-vcc/src/core/redact"; +import { TaskStore } from "@tintinweb/pi-tasks/src/task-store.js"; + +function shellQuote(value: string): string { + if (value.length === 0) return "''"; + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function getPiInvocationParts(): string[] { + const currentScript = process.argv[1]; + if (currentScript && existsSync(currentScript)) { + return [process.execPath, currentScript]; + } + const execName = path.basename(process.execPath).toLowerCase(); + if (!/^(node|bun)(\.exe)?$/.test(execName)) { + return [process.execPath]; + } + return ["pi"]; +} + +interface HandoffModel { + key: string; + label: string; + provider: string; + model: string; + thinking?: string; +} + +const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; +type ThinkingLevel = (typeof THINKING_LEVELS)[number]; + +interface SelectedModel { + provider: string; + model: string; + thinking: ThinkingLevel; +} + +function loadFavouriteModels(): HandoffModel[] { + const configPath = join(dirname(new URL(import.meta.url).pathname), "../leader-key/favourite-models.json"); + try { + const raw = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(raw) as HandoffModel[]; + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (e) => typeof e.key === "string" && typeof e.label === "string" && typeof e.provider === "string" && typeof e.model === "string", + ); + } catch { + return []; + } +} + +async function pickModel(ctx: ExtensionContext): Promise { + const favourites = loadFavouriteModels(); + if (favourites.length === 0) { + ctx.ui.notify("No favourite models configured. Edit leader-key/favourite-models.json", "warning"); + return null; + } + + interface PickerEntry { + fav: HandoffModel; + thinkingIndex: number; + } + + const defaultThinkingIndex = THINKING_LEVELS.indexOf("medium"); + const entries: PickerEntry[] = favourites.map((fav) => { + const favThinking = fav.thinking as ThinkingLevel | undefined; + const idx = favThinking ? THINKING_LEVELS.indexOf(favThinking) : -1; + return { + fav, + thinkingIndex: idx >= 0 ? idx : defaultThinkingIndex, + }; + }); + + return ctx.ui.custom( + (tui, theme, _kb, done) => { + let highlightedIndex = 0; + const th = theme; + + return { + render: (width: number) => { + const f = new OverlayFrame(width, th); + const lines: string[] = []; + + lines.push(f.top()); + lines.push(f.row(th.fg("accent", th.bold("Handoff Model")))); + lines.push(f.separator()); + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const isHighlighted = i === highlightedIndex; + + const keyTag = th.fg("dim", `[${entry.fav.key}]`); + const label = isHighlighted + ? th.fg("accent", th.bold(entry.fav.label)) + : th.fg("text", entry.fav.label); + const providerTag = th.fg("dim", `(${entry.fav.provider})`); + + const thinking = THINKING_LEVELS[entry.thinkingIndex]; + const thinkingRole = (THINKING_ROLES[thinking] ?? "dim") as Parameters[0]; + const thinkingTag = isHighlighted + ? th.fg("dim", "\u2039") + th.fg(thinkingRole, ` ${thinking} `) + th.fg("dim", "\u203a") + : th.fg(thinkingRole, thinking); + + const line = `${isHighlighted ? ">" : " "} ${keyTag} ${label} ${providerTag} ${thinkingTag}`; + lines.push(f.rowTruncated(line)); + } + + lines.push(f.separator()); + lines.push(f.row(th.fg("dim", "enter select | h/l thinking | j/k navigate | esc cancel"))); + lines.push(f.bottom()); + + return lines; + }, + invalidate: () => {}, + handleInput: (data: string) => { + if (matchesKey(data, "escape") || matchesKey(data, Key.ctrl("c"))) { + done(null); + return; + } + + if (matchesKey(data, "up") || matchesKey(data, Key.ctrl("p")) || data === "k") { + highlightedIndex = Math.max(0, highlightedIndex - 1); + tui.requestRender(); + return; + } + if (matchesKey(data, "down") || matchesKey(data, Key.ctrl("n")) || data === "j") { + highlightedIndex = Math.min(entries.length - 1, highlightedIndex + 1); + tui.requestRender(); + return; + } + + if (matchesKey(data, "left") || data === "h") { + const entry = entries[highlightedIndex]; + entry.thinkingIndex = (entry.thinkingIndex - 1 + THINKING_LEVELS.length) % THINKING_LEVELS.length; + tui.requestRender(); + return; + } + if (matchesKey(data, "right") || data === "l") { + const entry = entries[highlightedIndex]; + entry.thinkingIndex = (entry.thinkingIndex + 1) % THINKING_LEVELS.length; + tui.requestRender(); + return; + } + + // Quick-select by key + const matchIndex = entries.findIndex((e) => e.fav.key === data); + if (matchIndex >= 0) { + const entry = entries[matchIndex]; + const thinking = THINKING_LEVELS[entry.thinkingIndex]; + done({ provider: entry.fav.provider, model: entry.fav.model, thinking }); + return; + } + + if (matchesKey(data, "enter")) { + const entry = entries[highlightedIndex]; + const thinking = THINKING_LEVELS[entry.thinkingIndex]; + done({ provider: entry.fav.provider, model: entry.fav.model, thinking }); + return; + } + }, + }; + }, + { overlay: true }, + ); +} + +function buildPiCommand(prompt: string, model?: SelectedModel): string { + const parts = [...getPiInvocationParts()]; + if (model) { + parts.push("--model", `${model.provider}/${model.model}`, "--thinking", model.thinking); + } + if (prompt.length > 0) { + parts.push(prompt); + } + return parts.map(shellQuote).join(" "); +} + +const DEFAULT_HANDOFF_GOAL = "Continue from the previous session and pick up the next sensible step."; + +function normalizeGoal(goal: string | undefined): string { + const trimmed = goal?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_HANDOFF_GOAL; +} + +/** + * Build a context summary using pi-vcc's building blocks but WITHOUT the + * 120-line capBrief truncation. Standard vccCompile loses the first half of + * longer conversations — this version keeps the full brief transcript. + * + * Typical sizes: + * - capped vcc: ~4K tokens (loses early decisions) + * - uncapped: ~9K tokens (keeps all user+assistant messages) + * - full serial: ~80K tokens (includes tool results) + */ +function buildContextSummary(ctx: ExtensionContext): string | null { + const branch = ctx.sessionManager.getBranch(); + const messages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); + + if (messages.length === 0) return null; + + const llmMessages = convertToLlm(messages); + const blocks = filterNoise(normalize(llmMessages)); + const data = buildSections({ blocks }); + + const section = (title: string, items: string[]): string => { + if (items.length === 0) return ""; + return `[${title}]\n${items.map((i) => `- ${i}`).join("\n")}`; + }; + + const headerParts = [ + section("Session Goal", data.sessionGoal), + section("Files And Changes", data.filesAndChanges), + section("Outstanding Context", data.outstandingContext), + section("User Preferences", data.userPreferences), + ].filter(Boolean); + + const parts: string[] = []; + if (headerParts.length > 0) parts.push(headerParts.join("\n\n")); + if (data.briefTranscript) parts.push(data.briefTranscript); // NO capBrief! + + if (parts.length === 0) return null; + + parts.push( + "Do not redo work already completed. Use `session_query` on the parent session if you need more details.", + ); + + return redact(parts.join("\n\n---\n\n")); +} + +// ── Language detection word lists ── +// High-frequency words per language that appear even without diacritics. +const LANG_WORDS: Record> = { + Czech: new Set([ + "je", "se", "na", "to", "ze", "jak", "ale", "nebo", "tak", "jsem", + "bych", "neni", "jeste", "taky", "proste", "treba", "tohle", "potom", + "kdyz", "jako", "protoze", "docela", "vlastne", "udelat", "podivat", + "pojdme", "muzeme", "nejakej", "zkusit", "myslim", "mohli", "dobry", + "chci", "neco", "delat", "bude", "jsou", "bylo", "jsme", "mame", + "potreba", "funguje", "nemyslim", "udelej", "podivej", "prosim", + ]), + German: new Set([ + "ich", "ist", "das", "nicht", "ein", "und", "der", "die", "wir", + "auch", "aber", "noch", "dann", "wenn", "oder", "kann", "schon", + "jetzt", "hier", "haben", "wird", "gibt", "muss", "ganz", "sehr", + ]), + French: new Set([ + "est", "les", "des", "une", "que", "pas", "pour", "dans", "avec", + "sur", "mais", "plus", "tout", "aussi", "bien", "peut", "faire", + "comme", "cette", "sont", "nous", "vous", "tres", "donc", "voila", + ]), + Spanish: new Set([ + "que", "los", "las", "una", "con", "por", "para", "pero", "como", + "mas", "muy", "todo", "esta", "puede", "hacer", "tiene", "desde", + "tambien", "cuando", "donde", "ahora", "hay", "algo", "mejor", "creo", + ]), + Polish: new Set([ + "nie", "jest", "sie", "jak", "ale", "czy", "tak", "jeszcze", "jest", + "moze", "tylko", "trzeba", "tutaj", "dobrze", "teraz", "jesli", + "bardzo", "kiedy", "albo", "troche", "mozna", "gdzie", "wszystko", + ]), +}; + +/** + * Detect the dominant non-English language from user messages. + * Uses both Unicode range detection (for diacritics) and word-frequency + * matching (for ASCII-only variants like Czech without háčky). + */ +function detectLanguage(ctx: ExtensionContext): { language: string; sample: string } | null { + const branch = ctx.sessionManager.getBranch(); + const userMessages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message) + .filter((m) => m.role === "user"); + + if (userMessages.length === 0) return null; + + const recentTexts = userMessages + .slice(-5) + .flatMap((m) => m.content) + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(" "); + + if (!recentTexts.trim()) return null; + + // 1. Unicode diacritics check (catches properly-typed non-English) + const hasNonAscii = /[\u00C0-\u024F\u0400-\u04FF\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF]/.test(recentTexts); + + // 2. Word-frequency check (catches ASCII-only non-English like Czech without háčky) + const words = recentTexts.toLowerCase().split(/\s+/); + const scores: Record = {}; + for (const [lang, wordSet] of Object.entries(LANG_WORDS)) { + let hits = 0; + for (const w of words) { + if (wordSet.has(w)) hits++; + } + scores[lang] = hits; + } + const bestLang = Object.entries(scores).sort((a, b) => b[1] - a[1])[0]; + const hasWordMatch = bestLang && bestLang[1] >= 3; + + if (!hasNonAscii && !hasWordMatch) return null; + + const language = hasWordMatch ? bestLang![0] : "non-English"; + return { language, sample: recentTexts.slice(0, 300) }; +} + +// ── Git state ── + +/** + * Collect git state: branch, staged files, modified files. + * Returns null if not in a git repo. Total exec time: ~26ms. + */ +function buildGitState(): string | null { + try { + const branch = execFileSync("git", ["branch", "--show-current"], { encoding: "utf8", timeout: 2000 }).trim(); + const status = execFileSync("git", ["status", "--porcelain"], { encoding: "utf8", timeout: 2000 }).trim(); + + if (!branch && !status) return null; + + const lines: string[] = []; + if (branch) lines.push(`Branch: ${branch}`); + + if (status) { + const staged: string[] = []; + const modified: string[] = []; + const untracked: string[] = []; + for (const line of status.split("\n")) { + const idx = line[0]; + const wt = line[1]; + const file = line.slice(3); + if (idx === "?" && wt === "?") { untracked.push(file); continue; } + if (idx && idx !== " " && idx !== "?") staged.push(file); + if (wt && wt !== " " && wt !== "?") modified.push(file); + } + if (staged.length > 0) lines.push(`Staged: ${staged.slice(0, 10).join(", ")}${staged.length > 10 ? ` (+${staged.length - 10} more)` : ""}`); + if (modified.length > 0) lines.push(`Modified (unstaged): ${modified.slice(0, 10).join(", ")}${modified.length > 10 ? ` (+${modified.length - 10} more)` : ""}`); + if (untracked.length > 0) lines.push(`Untracked: ${untracked.length} file(s)`); + } else { + lines.push("Working tree clean"); + } + + return lines.join("\n"); + } catch { + return null; + } +} + +// ── Working set extraction ── + +const FILE_PATH_TOOLS = new Set(["read", "edit", "write", "Read", "Edit", "Write"]); + +/** + * Extract the most recently touched files from the last N assistant messages. + * Returns a deduplicated list with the most recent files first. + */ +function extractWorkingFiles(ctx: ExtensionContext, maxFiles = 5): string[] { + const branch = ctx.sessionManager.getBranch(); + const assistantMessages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message) + .filter((m) => m.role === "assistant"); + + // Scan from the end to get the most recent files + const seen = new Set(); + const result: string[] = []; + + for (let i = assistantMessages.length - 1; i >= 0 && result.length < maxFiles; i--) { + const msg = assistantMessages[i]; + for (const block of msg.content) { + if (block.type !== "toolCall") continue; + if (!FILE_PATH_TOOLS.has(block.name)) continue; + const args = block.arguments as Record; + const filePath = (args.path ?? args.file_path ?? args.file) as string | undefined; + if (!filePath || seen.has(filePath)) continue; + seen.add(filePath); + result.push(filePath); + if (result.length >= maxFiles) break; + } + } + + return result; +} + +/** + * Resolve the task store file path for the current session. + * Mirrors pi-tasks logic: env PI_TASKS > tasks-config.json > default session scope. + */ +function resolveTaskStorePath(sessionId: string): string | undefined { + const piTasks = process.env.PI_TASKS; + if (piTasks === "off") return undefined; + if (piTasks?.startsWith("/")) return piTasks; + if (piTasks?.startsWith(".")) return resolve(piTasks); + if (piTasks) return piTasks; + + // Read tasks-config.json for scope + let taskScope = "session"; + try { + const cfg = JSON.parse(readFileSync(join(process.cwd(), ".pi", "tasks-config.json"), "utf-8")); + if (cfg.taskScope) taskScope = cfg.taskScope; + } catch { /* no config — default session scope */ } + + if (taskScope === "memory") return undefined; + if (taskScope === "session") return join(process.cwd(), ".pi", "tasks", `tasks-${sessionId}.json`); + return join(process.cwd(), ".pi", "tasks", "tasks.json"); +} + +interface HandoffTask { + id: string; + subject: string; + status: string; + description: string; + blockedBy: string[]; +} + +/** + * Read current tasks from pi-tasks store. + */ +function readTasks(sessionId: string): HandoffTask[] | null { + + const storePath = resolveTaskStorePath(sessionId); + if (!storePath || !existsSync(storePath)) return null; + + try { + const store = new TaskStore(storePath); + const tasks = store.list(); + if (tasks.length === 0) return null; + return tasks; + } catch { + return null; + } +} + +/** + * Format tasks into a section with instructions for the new session. + * Incomplete tasks get a TaskCreate instruction; completed ones are listed for reference. + * Uses subject-based references for blockedBy (IDs won't survive handoff). + */ +function formatTasksSection(tasks: HandoffTask[]): string { + const incomplete = tasks.filter((t) => t.status !== "completed"); + const completed = tasks.filter((t) => t.status === "completed"); + + // Build ID → subject map for dependency resolution + const idToSubject = new Map(); + for (const t of tasks) idToSubject.set(t.id, t.subject); + + const lines: string[] = []; + + if (incomplete.length > 0) { + lines.push( + "**IMPORTANT: Before doing anything else, recreate the following tasks using TaskCreate" + + " (one call per task). Preserve the original subject, description, and dependency structure." + + " Tasks that were in_progress should be created as pending." + + " Use addBlockedBy with the NEW task IDs after creating all tasks.**\n", + ); + for (const t of incomplete) { + lines.push(`- [${t.status}] ${t.subject}`); + if (t.description) lines.push(` Description: ${t.description}`); + if (t.blockedBy.length > 0) { + const deps = t.blockedBy.map((b) => { + const subj = idToSubject.get(b); + return subj ? `"${subj}"` : `#${b}`; + }); + lines.push(` Blocked by: ${deps.join(", ")}`); + } + } + } + + if (completed.length > 0) { + lines.push("\nAlready completed (for reference only, do NOT recreate):"); + for (const t of completed) { + lines.push(`- ✅ ${t.subject}`); + } + } + + return lines.join("\n"); +} + +/** + * Replace the vcc_recall note with a handoff-appropriate version. + * The original references vcc_recall which doesn't exist in the new session. + */ +function replaceRecallNote(summary: string): string { + return summary.replace( + /Use `vcc_recall` to search for prior work[^]*?Do not redo work already completed\./g, + "Do not redo work already completed. Use `session_query` on the parent session if you need more details.", + ).trim(); +} + +interface HandoffContext { + goal: string | undefined; + summary: string; + tasks: HandoffTask[] | null; + sessionFile: string | undefined; + language: { language: string; sample: string } | null; + gitState: string | null; + workingFiles: string[]; + artifactPath: string | null; +} + +/** + * Build the final handoff prompt. + */ +function buildHandoffPrompt(hctx: HandoffContext): string { + const parts: string[] = []; + const effectiveGoal = normalizeGoal(hctx.goal); + + parts.push(effectiveGoal); + + // ── Language ── + if (hctx.language) { + parts.push( + `\n**IMPORTANT: Continue in ${hctx.language.language} — the same language as the previous session.**` + + " Here is a sample of how the user was communicating:" + + `\n> ${hctx.language.sample.slice(0, 200)}`, + ); + } + + // ── Parent session ── + if (hctx.sessionFile) { + parts.push( + `\n**Parent session:** \`${hctx.sessionFile}\`\n` + + "Use `session_query` on the parent session if you need more details beyond the summary below.", + ); + } + + // ── Handoff artifact ── + if (hctx.artifactPath) { + parts.push(`**Handoff artifact:** \`${hctx.artifactPath}\` (re-read if you need the full context again)`); + } + + // ── Instructions for context continuity ── + parts.push( + "\n## Instructions" + + "\n\n**FIRST STEP: Before doing ANY work, carefully read the context summary below." + + " Identify the key decisions, agreements, and chosen approach from the previous session." + + " State them back briefly, then proceed with the goal." + + " Honor previous decisions unless the user explicitly changes direction." + + " Pay special attention to [user] entries in the transcript — that's where decisions are.**", + ); + + // ── Git state ── + if (hctx.gitState) { + parts.push(`\n## Git State\n\n${hctx.gitState}`); + } + + // ── Working files ── + if (hctx.workingFiles.length > 0) { + parts.push( + `\n## Current Working Files (most recently touched)\n\n` + + hctx.workingFiles.map((f) => `- ${f}`).join("\n"), + ); + } + + // ── Tasks ── + if (hctx.tasks && hctx.tasks.length > 0) { + parts.push(`\n## Tasks from previous session\n\n${formatTasksSection(hctx.tasks)}`); + } + + // ── Context summary (vcc) ── + parts.push(`\n## Context from previous session\n\n${replaceRecallNote(hctx.summary)}`); + + return parts.join("\n"); +} + +function cmuxShellEscape(value: string): string { + return `'${value.replace(/'/g, "'\"'\"'")}'`; +} + +/** + * Save handoff prompt as an artifact for resilience and debuggability. + * Returns the artifact file path, or null on failure. + */ +function saveHandoffArtifact(prompt: string, sessionId: string): string | null { + try { + const dir = join(process.cwd(), ".pi", "handoffs"); + mkdirSync(dir, { recursive: true }); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const shortId = sessionId.slice(0, 8); + const filePath = join(dir, `${ts}_${shortId}.md`); + writeFileSync(filePath, prompt, "utf-8"); + return filePath; + } catch { + return null; + } +} + +/** + * Core handoff: generate summary, open cmux split, launch pi with prompt. + * All operations are algorithmic (no LLM calls) for speed. + */ +async function performHandoff( + ctx: ExtensionContext, + goal?: string, + model?: SelectedModel, +): Promise { + if (!ctx.hasUI) return "Handoff requires interactive mode."; + if (!process.env.CMUX_SOCKET_PATH) return "cmux is not available. Cannot open a split."; + + // Start cmux split immediately (runs in parallel with prompt building) + const splitPromise = new Promise((resolve) => { + try { + const out = execSync("cmux new-split right", { encoding: "utf8" }).trim(); + const match = out.match(/surface:\d+/); + if (!match) { resolve(`Unexpected cmux new-split output: ${out}`); return; } + const surface = match[0]; + execSync(`cmux rename-tab --surface ${cmuxShellEscape(surface)} ${cmuxShellEscape("handoff")}`, { encoding: "utf8" }); + resolve(surface); + } catch (err) { + resolve(`cmux error: ${err}`); + } + }); + + // Build prompt concurrently with cmux split + const summary = buildContextSummary(ctx); + if (!summary) return "No conversation to hand off."; + + const sessionFile = ctx.sessionManager.getSessionFile(); + const sessionId = ctx.sessionManager.getSessionId(); + + const hctx: HandoffContext = { + goal, + summary, + tasks: readTasks(sessionId), + sessionFile, + language: detectLanguage(ctx), + gitState: buildGitState(), + workingFiles: extractWorkingFiles(ctx), + artifactPath: null, // set after saving + }; + + // Save artifact + const prompt = buildHandoffPrompt(hctx); + const artifactPath = saveHandoffArtifact(prompt, sessionId); + if (artifactPath) { + hctx.artifactPath = artifactPath; + } + // Rebuild with artifact path included + const finalPrompt = artifactPath ? buildHandoffPrompt(hctx) : prompt; + + // Wait for cmux split to be ready + const surfaceOrError = await splitPromise; + if (!surfaceOrError || !surfaceOrError.startsWith("surface:")) { + return surfaceOrError ?? "Failed to create cmux split."; + } + const surface = surfaceOrError; + + // Wait for shell to initialize + await new Promise((r) => setTimeout(r, 500)); + + // Launch pi with the handoff prompt + const command = buildPiCommand(finalPrompt, model); + execSync(`cmux send --surface ${cmuxShellEscape(surface)} ${cmuxShellEscape(command + "\n")}`, { + encoding: "utf8", + }); + + return undefined; +} + +export default function (pi: ExtensionAPI) { + // /handoff command + pi.registerCommand("handoff", { + description: "Transfer context to a new pi session in a cmux split. Usage: /handoff [optional goal]", + handler: async (args, ctx) => { + const model = await pickModel(ctx); + if (!model) return; // cancelled + const error = await performHandoff(ctx, args, model); + if (error) { + ctx.ui.notify(error, "error"); + } else { + ctx.ui.notify("Handoff sent to new cmux split.", "info"); + } + }, + }); + + // handoff tool (agent-callable) + pi.registerTool({ + name: "handoff", + label: "Handoff", + description: + "Transfer context to a new pi session in a separate cmux split. ONLY use when the user explicitly asks for a handoff. Goal is optional; if omitted, the new session continues from the previous one.", + parameters: Type.Object({ + goal: Type.Optional(Type.String({ description: "Optional goal/task for the new session" })), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const effectiveGoal = normalizeGoal(params.goal); + const error = await performHandoff(ctx, params.goal); + if (error) { + return { + content: [{ type: "text" as const, text: `Handoff failed: ${error}` }], + details: { error: true }, + }; + } + return { + content: [{ type: "text" as const, text: `Handoff started in a new cmux split with goal: ${effectiveGoal}` }], + details: { goal: effectiveGoal }, + }; + }, + }); +} diff --git a/extensions/leader-key/context-helpers.ts b/extensions/leader-key/context-helpers.ts new file mode 100644 index 0000000..ba8d63e --- /dev/null +++ b/extensions/leader-key/context-helpers.ts @@ -0,0 +1,111 @@ +/** + * Safe helpers for ExtensionCommandContext methods. + * + * Shortcut handlers receive ExtensionContext (no session navigation methods). + * Command handlers receive ExtensionCommandContext (with switchSession, etc.). + * These helpers do a runtime check and fall back to internal slash commands + * (which do get ExtensionCommandContext). + */ + +import type { + ExtensionAPI, + ExtensionContext, + ExtensionCommandContext, +} from "@mariozechner/pi-coding-agent"; +import type { SessionTreeNode } from "@mariozechner/pi-coding-agent/dist/core/session-manager.js"; + +/** Check if ctx has full command context capabilities. */ +function isCommandCtx(ctx: ExtensionContext): ctx is ExtensionCommandContext { + return "switchSession" in ctx && typeof (ctx as any).switchSession === "function"; +} + +/** Emit a slash command via the editor input. */ +function emitCommand(ctx: ExtensionContext, command: string) { + ctx.ui.setEditorText(command); + setTimeout(() => process.stdin.emit("data", "\r"), 0); +} + +/** + * Navigate to a tree entry. Uses the direct API when available, + * falls back to the internal /lk-navigate command. + */ +export async function tryNavigateTree(ctx: ExtensionContext, targetId: string) { + if (isCommandCtx(ctx)) { + await ctx.navigateTree(targetId); + } else { + emitCommand(ctx, `/lk-navigate ${targetId}`); + } +} + +/** + * Switch to a different session file. Uses the direct API when available, + * falls back to the internal /lk-switch command. + */ +export async function trySwitchSession(ctx: ExtensionContext, sessionPath: string) { + if (isCommandCtx(ctx)) { + await ctx.switchSession(sessionPath); + } else { + emitCommand(ctx, `/lk-switch ${sessionPath}`); + } +} + +/** + * Register internal bridge commands that provide ExtensionCommandContext + * for actions triggered from shortcut handlers. + */ +export function registerBridgeCommands(pi: ExtensionAPI) { + pi.registerCommand("lk-navigate", { + description: "(internal) Navigate to a tree entry by ID", + handler: async (args, ctx) => { + const targetId = args.trim(); + if (!targetId) return; + await ctx.navigateTree(targetId); + }, + }); + + pi.registerCommand("lk-switch", { + description: "(internal) Switch to a session file by path", + handler: async (args, ctx) => { + const sessionPath = args.trim(); + if (!sessionPath) return; + await ctx.switchSession(sessionPath); + }, + }); +} + +/** Entry preview extracted from the tree node. */ +export interface LabeledEntry { + id: string; + label: string; + preview: string; +} + +/** + * Collect all labeled entries from the session tree. + * Shared by "Clear label" and "Go to label" actions. + */ +export function collectLabeledEntries(tree: SessionTreeNode[]): LabeledEntry[] { + const labeled: LabeledEntry[] = []; + + function walk(nodes: SessionTreeNode[]) { + for (const node of nodes) { + if (node.label) { + let preview = ""; + if (node.entry.type === "message") { + const msg = node.entry.message; + if (typeof msg.content === "string") { + preview = msg.content.slice(0, 60); + } else if (Array.isArray(msg.content)) { + const text = msg.content.find((c: any) => c.type === "text"); + if (text && "text" in text) preview = (text as any).text.slice(0, 60); + } + } + labeled.push({ id: node.entry.id, label: node.label, preview }); + } + walk(node.children); + } + } + + walk(tree); + return labeled; +} diff --git a/extensions/leader-key/favourite-models.json b/extensions/leader-key/favourite-models.json index a176f44..088413a 100644 --- a/extensions/leader-key/favourite-models.json +++ b/extensions/leader-key/favourite-models.json @@ -1,58 +1,44 @@ [ { - "key": "a", - "label": "Anthropic Opus 4.6", + "label": "Opus 4.6", "provider": "anthropic", "model": "claude-opus-4-6", "thinking": "medium" }, { - "key": "s", - "label": "Anthropic Sonnet 4.6", + "label": "Sonnet 4.6", "provider": "anthropic", "model": "claude-sonnet-4-6", "thinking": "medium" }, { - "key": "d", - "label": "Copilot Opus 4.6", + "label": "Opus 4.6", "provider": "github-copilot", "model": "claude-opus-4.6", "thinking": "medium" }, { - "key": "f", - "label": "Copilot Sonnet 4.6", + "label": "Sonnet 4.6", "provider": "github-copilot", "model": "claude-sonnet-4.6", "thinking": "medium" }, { - "key": "j", - "label": "Copilot GPT-5.4", + "label": "GPT-5.4", "provider": "github-copilot", "model": "gpt-5.4", "thinking": "medium" }, { - "key": "k", - "label": "Copilot GPT-5.3 Codex", - "provider": "github-copilot", - "model": "gpt-5.3-codex", - "thinking": "medium" - }, - { - "key": "l", - "label": "Copilot Sonnet 4.5", - "provider": "github-copilot", - "model": "claude-sonnet-4.5", + "label": "GPT-5.4", + "provider": "openai-codex", + "model": "gpt-5.4", "thinking": "medium" }, { - "key": ";", - "label": "Copilot Haiku 4.5", - "provider": "github-copilot", - "model": "claude-haiku-4.5", + "label": "Opus 4.7", + "provider": "anthropic", + "model": "claude-opus-4-7", "thinking": "medium" } ] diff --git a/extensions/leader-key/favourite-models.ts b/extensions/leader-key/favourite-models.ts index 935b392..60b33fb 100644 --- a/extensions/leader-key/favourite-models.ts +++ b/extensions/leader-key/favourite-models.ts @@ -1,31 +1,30 @@ /** * Favourite Models Picker * - * Quick-switch to a favourite model using a single key press (1-6, a-z). - * Favourites are configured in favourite-models.json (max 6 entries). + * Quick-switch to a favourite model with inline thinking level. + * Favourites are configured in favourite-models.json (max 8 entries). * * Each entry specifies: - * - key: single character shortcut (number or letter) * - label: display name shown in the picker * - provider: provider name (e.g. "anthropic", "google", "openai") * - model: model id - * - thinking: (optional) thinking level to set after switching + * - thinking: (optional) default thinking level */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; -import { matchesKey, parseKey, Key } from "@mariozechner/pi-tui"; +import { matchesKey, Key } from "@mariozechner/pi-tui"; import { OverlayFrame } from "../shared/overlay.js"; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; -import { ALL_THINKING_LEVELS, searchableSelect, getThinkingDescription } from "./model-switcher"; +import { ALL_THINKING_LEVELS } from "./model-switcher"; +import { THINKING_ROLES } from "../shared/thinking-colors.js"; // ───────────────────────────────────────────────────────────────────────────── // Config types // ───────────────────────────────────────────────────────────────────────────── interface FavouriteModelEntry { - key: string; label: string; provider: string; model: string; @@ -50,8 +49,6 @@ function loadFavourites(): FavouriteModelEntry[] { return parsed .filter( (e) => - typeof e.key === "string" && - e.key.length === 1 && typeof e.label === "string" && typeof e.provider === "string" && typeof e.model === "string", @@ -66,6 +63,11 @@ function loadFavourites(): FavouriteModelEntry[] { // Favourite Models Picker UI // ───────────────────────────────────────────────────────────────────────────── +interface PickerResult { + fav: FavouriteModelEntry; + thinking: ThinkingLevel; +} + export async function runFavouriteModels(pi: ExtensionAPI, ctx: ExtensionContext): Promise { if (!ctx.hasUI) return; @@ -78,7 +80,13 @@ export async function runFavouriteModels(pi: ExtensionAPI, ctx: ExtensionContext const currentModel = ctx.model; const currentThinking = pi.getThinkingLevel(); - const selected = await ctx.ui.custom( + // Build per-entry thinking indices + const thinkingIndices: number[] = favourites.map((fav) => { + const idx = fav.thinking ? ALL_THINKING_LEVELS.indexOf(fav.thinking as ThinkingLevel) : -1; + return idx >= 0 ? idx : ALL_THINKING_LEVELS.indexOf(currentThinking); + }); + + const selected = await ctx.ui.custom( (tui, theme, _kb, done) => { let highlightedIndex = 0; const th = theme; @@ -88,90 +96,95 @@ export async function runFavouriteModels(pi: ExtensionAPI, ctx: ExtensionContext const f = new OverlayFrame(width, th); const lines: string[] = []; - // Header lines.push(f.top()); lines.push(f.row(th.fg("accent", th.bold("Favourite Models")))); lines.push(f.separator()); - // Items for (let i = 0; i < favourites.length; i++) { const fav = favourites[i]; const isHighlighted = i === highlightedIndex; - // Check if this is the current model const isCurrent = currentModel?.provider === fav.provider && - currentModel?.id === fav.model && - (!fav.thinking || currentThinking === fav.thinking); + currentModel?.id === fav.model; - const keyBadge = th.fg("warning", th.bold(`[${fav.key}]`)); const label = isHighlighted ? th.fg("accent", th.bold(fav.label)) : th.fg("text", fav.label); + const providerTag = th.fg("dim", `(${fav.provider})`); const currentBadge = isCurrent ? " " + th.fg("success", "●") : ""; - let desc = th.fg("dim", `${fav.provider}/${fav.model}`); - if (fav.thinking) { - desc += th.fg("dim", ` (${fav.thinking})`); - } + const thinking = ALL_THINKING_LEVELS[thinkingIndices[i]]; + const thinkingRole = THINKING_ROLES[thinking] ?? "dim"; + const thinkingTag = isHighlighted + ? th.fg("dim", "‹") + th.fg(thinkingRole, ` ${thinking} `) + th.fg("dim", "›") + : th.fg(thinkingRole, thinking); - let line = `${isHighlighted ? "> " : " "}${keyBadge} ${label}${currentBadge} ${desc}`; + const num = th.fg("dim", `${i + 1}`); + const line = `${isHighlighted ? "> " : " "}${num} ${label} ${providerTag}${currentBadge} ${thinkingTag}`; lines.push(f.rowTruncated(line)); } - // Footer lines.push(f.separator()); - lines.push(f.row(th.fg("dim", "press key to switch | ↑↓ navigate | enter select | esc cancel"))); + lines.push(f.row(th.fg("dim", "j/k navigate | 1-8 jump | h/l thinking | enter select | esc cancel"))); lines.push(f.bottom()); return lines; }, invalidate: () => {}, handleInput: (data: string) => { - // Escape / Ctrl+C: cancel if (matchesKey(data, "escape") || matchesKey(data, Key.ctrl("c"))) { done(null); return; } - // Backspace: cancel if (matchesKey(data, "backspace")) { done(null); return; } - // Arrow keys - if (matchesKey(data, "up") || matchesKey(data, Key.ctrl("p"))) { + // Navigate: arrows + vim j/k + if (matchesKey(data, "up") || matchesKey(data, Key.ctrl("p")) || data === "k") { highlightedIndex = Math.max(0, highlightedIndex - 1); tui.requestRender(); return; } - if (matchesKey(data, "down") || matchesKey(data, Key.ctrl("n"))) { + if (matchesKey(data, "down") || matchesKey(data, Key.ctrl("n")) || data === "j") { highlightedIndex = Math.min(favourites.length - 1, highlightedIndex + 1); tui.requestRender(); return; } - // Enter: select highlighted - if (matchesKey(data, "enter")) { - if (highlightedIndex >= 0 && highlightedIndex < favourites.length) { - done(favourites[highlightedIndex]); - } + // Thinking: arrows + vim h/l + if (matchesKey(data, "left") || data === "h") { + thinkingIndices[highlightedIndex] = (thinkingIndices[highlightedIndex] - 1 + ALL_THINKING_LEVELS.length) % ALL_THINKING_LEVELS.length; + tui.requestRender(); + return; + } + if (matchesKey(data, "right") || data === "l") { + thinkingIndices[highlightedIndex] = (thinkingIndices[highlightedIndex] + 1) % ALL_THINKING_LEVELS.length; + tui.requestRender(); return; } - // Direct key press — match favourite key - const parsed = parseKey(data); - const keyChar = parsed && parsed.length === 1 ? parsed.toLowerCase() : null; - const rawChar = data.length === 1 && data >= " " && data <= "~" ? data.toLowerCase() : null; - const pressedKey = keyChar || rawChar; + // Number keys 1-8: jump to entry + const num = parseInt(data, 10); + if (num >= 1 && num <= favourites.length) { + highlightedIndex = num - 1; + tui.requestRender(); + return; + } - if (pressedKey) { - const match = favourites.find((f) => f.key.toLowerCase() === pressedKey); - if (match) { - done(match); + // Enter: select highlighted + if (matchesKey(data, "enter")) { + if (highlightedIndex >= 0 && highlightedIndex < favourites.length) { + done({ + fav: favourites[highlightedIndex], + thinking: ALL_THINKING_LEVELS[thinkingIndices[highlightedIndex]], + }); } + return; } }, }; @@ -189,52 +202,21 @@ export async function runFavouriteModels(pi: ExtensionAPI, ctx: ExtensionContext if (!selected) return; - // ── Validate model before showing thinking picker ───────────────────── - const modelInfo = ctx.modelRegistry.find(selected.provider, selected.model); + const modelInfo = ctx.modelRegistry.find(selected.fav.provider, selected.fav.model); if (!modelInfo) { - ctx.ui.notify(`Model ${selected.provider}/${selected.model} not found in registry`, "error"); + ctx.ui.notify(`Model ${selected.fav.provider}/${selected.fav.model} not found in registry`, "error"); return; } - // ── Step 2: Pick thinking level (before applying model → no delay) ── - const supportsReasoning = modelInfo.reasoning; - let selectedThinking: ThinkingLevel = pi.getThinkingLevel(); - - if (supportsReasoning) { - const defaultThinking = selected.thinking && ALL_THINKING_LEVELS.includes(selected.thinking as ThinkingLevel) - ? (selected.thinking as ThinkingLevel) - : pi.getThinkingLevel(); - - const thinkingItems = ALL_THINKING_LEVELS.map((level) => ({ - value: level, - label: level === defaultThinking ? `${level} (default)` : level, - description: getThinkingDescription(level), - })); - - const thinkingChoice = await searchableSelect( - ctx, - `Thinking Level (${selected.label})`, - thinkingItems, - undefined, - defaultThinking, - ); - - if (!thinkingChoice) return; - selectedThinking = thinkingChoice; - } else { - selectedThinking = "off"; - } - - // ── Apply model + thinking together ─────────────────────────────────── const ok = await pi.setModel(modelInfo); if (!ok) { - ctx.ui.notify(`No API key available for ${selected.provider}/${selected.model}`, "warning"); + ctx.ui.notify(`No API key available for ${selected.fav.provider}/${selected.fav.model}`, "warning"); return; } - pi.setThinkingLevel(selectedThinking); + pi.setThinkingLevel(selected.thinking); ctx.ui.notify( - `Switched to ${selected.label}${supportsReasoning ? ` (thinking: ${selectedThinking})` : ""}`, + `Switched to ${selected.fav.label} (thinking: ${selected.thinking})`, "info", ); } diff --git a/extensions/leader-key/index.ts b/extensions/leader-key/index.ts index c27dfc1..2e545c3 100644 --- a/extensions/leader-key/index.ts +++ b/extensions/leader-key/index.ts @@ -1,7 +1,7 @@ /** * Leader Key Extension * - * Press Ctrl+Space to open a floating command palette showing all available + * Press Ctrl+X to open a floating command palette showing all available * actions organised into groups (like Vim's which-key or Emacs' leader key). * * Each group has a single-character chord key. Press the chord to see the @@ -21,160 +21,50 @@ import type { ExtensionContext, Theme, } from "@mariozechner/pi-coding-agent"; -import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; import { matchesKey, parseKey, Key } from "@mariozechner/pi-tui"; -import { runModelSwitcher, runThinkingPicker, searchableSelect } from "./model-switcher"; -import { runFavouriteModels } from "./favourite-models"; -import { runSessionSwitch } from "../session-switch/index"; +import { searchableSelect } from "./model-switcher.js"; +import { runFavouriteModels } from "./favourite-models.js"; import { OverlayFrame } from "../shared/overlay.js"; - - - -// ───────────────────────────────────────────────────────────────────────────── -// Types -// ───────────────────────────────────────────────────────────────────────────── - -interface ActionItem { - key: string; // single character shortcut - label: string; - description?: string; - action: (ctx: ExtensionContext) => void | Promise; -} - -interface ActionGroup { - key: string; // chord key to open this group - label: string; - items: ActionItem[]; -} - -/** Top-level entry: either a group (chord → submenu) or a direct action */ -type TopLevelEntry = - | { type: "group"; group: ActionGroup } - | { type: "action"; key: string; label: string; description?: string; action: (ctx: ExtensionContext) => void | Promise }; +import { copyToClipboard } from "../pi-telescope/clipboard.js"; +import type { ActionItem, ActionGroup, TopLevelEntry } from "./types.js"; +import { buildSessionEntries } from "./session-actions.js"; +import { buildLabelEntries } from "./label-actions.js"; +import { registerBridgeCommands } from "./context-helpers.js"; // ───────────────────────────────────────────────────────────────────────────── // Build top-level entries // ───────────────────────────────────────────────────────────────────────────── -// ───────────────────────────────────────────────────────────────────────────── - - function buildEntries(pi: ExtensionAPI, ctx: ExtensionContext): TopLevelEntry[] { const entries: TopLevelEntry[] = []; // ── Session ───────────────────────────────────────────────────────── - entries.push({ - type: "group", - group: { - key: "s", - label: "Session", - items: [ - { - key: "n", - label: "New session", - description: "start fresh", - action: (ctx) => { - ctx.ui.setEditorText("/new"); - setTimeout(() => process.stdin.emit("data", "\r"), 0); - }, - }, - { - key: "r", - label: "Resume session", - description: "/resume", - action: (ctx) => { - ctx.ui.setEditorText("/resume"); - setTimeout(() => process.stdin.emit("data", "\r"), 0); - }, - }, - { - key: "s", - label: "Switch session", - description: "split panel picker", - action: (ctx) => runSessionSwitch(pi, ctx), - }, - { - key: "t", - label: "Session tree", - description: "/tree", - action: (ctx) => { - ctx.ui.setEditorText("/tree"); - setTimeout(() => process.stdin.emit("data", "\r"), 0); - }, - }, - { - key: "f", - label: "Fork session", - description: "/fork", - action: (ctx) => { - ctx.ui.setEditorText("/fork"); - setTimeout(() => process.stdin.emit("data", "\r"), 0); - }, - }, - { - key: "p", - label: "Split-fork", - description: "fork into cmux split pane", - action: (ctx) => { - ctx.ui.setEditorText("/split-fork"); - setTimeout(() => process.stdin.emit("data", "\r"), 0); - }, - }, - { - key: "c", - label: "Compact context", - description: "compact now", - action: (ctx) => { - ctx.compact({}); - ctx.ui.notify("Compaction started", "info"); - }, - }, - ], - }, - }); + entries.push(buildSessionEntries(pi)); - // ── Model (direct action → opens model switcher wizard) ───────────── - const currentModel = ctx.model; - entries.push({ - type: "action", - key: "m", - label: "Model", - description: currentModel ? `${currentModel.provider}/${currentModel.id}` : "switch model", - action: (ctx) => runModelSwitcher(pi, ctx), - }); + // ── Labels ────────────────────────────────────────────────────────── + entries.push(buildLabelEntries(pi)); - // ── Favourite models (direct action → quick-switch picker) ────────── + // ── Favourite models ──────────────────────────────────────────────── entries.push({ type: "action", - key: "f", + key: "m", label: "Favourites", description: "quick-switch favourite models", action: (ctx) => runFavouriteModels(pi, ctx), }); - // ── Thinking (direct action → opens thinking level picker) ────────── - const currentThinking = pi.getThinkingLevel(); - entries.push({ - type: "action", - key: "t", - label: "Thinking", - description: `current: ${currentThinking}`, - action: (ctx) => runThinkingPicker(pi, ctx), - }); - - // ── Permissions mode (direct action → overlay picker) ─────────────── + // ── Permissions mode ──────────────────────────────────────────────── entries.push({ type: "action", key: "p", label: "Permissions", description: "switch permission mode", action: async (ctx) => { - const ALL_MODES = ["yolo", "safe", "plan", "read-only"] as const; + const ALL_MODES = ["yolo", "safe", "read-only"] as const; const MODE_DESCRIPTIONS: Record = { yolo: "all commands allowed, no checks", safe: "permission rules active", - plan: "plannotator planning mode", - "read-only": "read-only, no writes allowed", + "read-only": "read-only, no writes except /tmp", }; const items = ALL_MODES.map((m) => ({ @@ -200,12 +90,12 @@ function buildEntries(pi: ExtensionAPI, ctx: ExtensionContext): TopLevelEntry[] const commands = pi.getCommands(); const extCommands = commands.filter((c) => c.source === "extension"); - // Filter out commands that are already represented in built-in entries const builtinCommandNames = new Set([ "new", "resume", "tree", "fork", "compact", "model", "thinking", "tools", "reload", "switch", "lk", "leader-key", "mode", "permissions", + "lk-navigate", "lk-switch", // internal bridge commands ]); const customCommands = extCommands.filter((c) => !builtinCommandNames.has(c.name)); @@ -236,7 +126,7 @@ function buildEntries(pi: ExtensionAPI, ctx: ExtensionContext): TopLevelEntry[] }); } - // ── Skills (direct action → searchable picker) ───────────────────── + // ── Skills ────────────────────────────────────────────────────────── const skillCommands = commands.filter((c) => c.source === "skill"); if (skillCommands.length > 0) { @@ -266,7 +156,7 @@ function buildEntries(pi: ExtensionAPI, ctx: ExtensionContext): TopLevelEntry[] }); } - // ── Exit ───────────────────────────────────────────────────────────── + // ── Review / Annotate ─────────────────────────────────────────────── entries.push({ type: "action", key: "r", @@ -289,6 +179,42 @@ function buildEntries(pi: ExtensionAPI, ctx: ExtensionContext): TopLevelEntry[] }, }); + // ── Copy last response ────────────────────────────────────────────── + entries.push({ + type: "action", + key: "y", + label: "Copy last response", + description: "copy assistant message to clipboard", + action: (ctx: ExtensionContext) => { + const entries = ctx.sessionManager.getEntries(); + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (e.type === "message" && (e.message as any).role === "assistant") { + const content = (e.message as any).content; + const textParts: string[] = []; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === "text" && block.text) textParts.push(block.text); + } + } + const text = textParts.join("\n"); + if (text) { + if (copyToClipboard(text)) { + ctx.ui.notify(`Copied (${text.length} chars)`, "info"); + } else { + ctx.ui.notify("Clipboard copy failed", "error"); + } + } else { + ctx.ui.notify("Last response has no text content", "info"); + } + return; + } + } + ctx.ui.notify("No assistant message found", "info"); + }, + }); + + // ── Exit ───────────────────────────────────────────────────────────── entries.push({ type: "action", key: "q", @@ -520,6 +446,9 @@ class LeaderKeyOverlay { // ───────────────────────────────────────────────────────────────────────────── export default function leaderKeyExtension(pi: ExtensionAPI) { + // Register internal commands that bridge shortcut→command context + registerBridgeCommands(pi); + async function openLeaderKey(ctx: ExtensionContext) { if (!ctx.hasUI) return; @@ -557,21 +486,6 @@ export default function leaderKeyExtension(pi: ExtensionAPI) { } } - // ── Model-switcher commands (previously standalone extension) ──────── - pi.registerCommand("switch", { - description: "Switch model (provider → model → thinking level)", - handler: async (_args, ctx) => { - await runModelSwitcher(pi, ctx); - }, - }); - - pi.registerShortcut(Key.ctrlShift("m"), { - description: "Switch model (provider → model → thinking level)", - handler: async (ctx) => { - await runModelSwitcher(pi, ctx); - }, - }); - // Register as a command pi.registerCommand("lk", { description: "Open Leader Key palette", @@ -580,8 +494,8 @@ export default function leaderKeyExtension(pi: ExtensionAPI) { }, }); - // Register shortcut: Ctrl+Space - pi.registerShortcut(Key.ctrl("space"), { + // Register shortcut: Ctrl+X + pi.registerShortcut(Key.ctrl("x"), { description: "Open Leader Key", handler: async (ctx) => { await openLeaderKey(ctx); diff --git a/extensions/leader-key/label-actions.ts b/extensions/leader-key/label-actions.ts new file mode 100644 index 0000000..a165816 --- /dev/null +++ b/extensions/leader-key/label-actions.ts @@ -0,0 +1,141 @@ +/** + * Label (bookmark) related leader-key actions. + */ + +import type { + ExtensionAPI, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; +import type { SessionTreeNode } from "@mariozechner/pi-coding-agent/dist/core/session-manager.js"; +import type { TopLevelEntry } from "./types.js"; +import { searchableSelect } from "./model-switcher.js"; +import { collectLabeledEntries, tryNavigateTree } from "./context-helpers.js"; + +const PRESET_LABELS = [ + { key: "r", label: "research", description: "mark as research" }, + { key: "p", label: "plan", description: "mark as plan" }, +]; + +export function buildLabelEntries(pi: ExtensionAPI): TopLevelEntry { + return { + type: "group", + group: { + key: "l", + label: "Label", + items: [ + ...PRESET_LABELS.map((preset) => ({ + key: preset.key, + label: preset.label, + description: preset.description, + action: (ctx: ExtensionContext) => { + const leafId = ctx.sessionManager.getLeafId(); + if (!leafId) { + ctx.ui.notify("No current entry to label", "error"); + return; + } + pi.setLabel(leafId, preset.label); + ctx.ui.notify(`Labeled: ${preset.label}`, "info"); + }, + })), + { + key: "c", + label: "Custom label", + description: "type your own", + action: async (ctx: ExtensionContext) => { + const leafId = ctx.sessionManager.getLeafId(); + if (!leafId) { + ctx.ui.notify("No current entry to label", "error"); + return; + } + const allLabels = [...PRESET_LABELS.map((p) => p.label)]; + // Also collect labels already used in this session + function collectExistingLabels(nodes: SessionTreeNode[]) { + for (const node of nodes) { + if (node.label && !allLabels.includes(node.label)) { + allLabels.push(node.label); + } + collectExistingLabels(node.children); + } + } + collectExistingLabels(ctx.sessionManager.getTree()); + + const items = allLabels.map((l) => ({ + value: l, + label: l, + description: "label", + })); + const selected = await searchableSelect( + ctx, + "Pick or search label", + items, + "type to filter, enter to select", + ); + if (selected) { + pi.setLabel(leafId, selected); + ctx.ui.notify(`Labeled: ${selected}`, "info"); + } + }, + }, + { + key: "x", + label: "Clear label", + description: "pick labeled entry to clear", + action: async (ctx: ExtensionContext) => { + const labeled = collectLabeledEntries(ctx.sessionManager.getTree()); + + if (labeled.length === 0) { + ctx.ui.notify("No labeled entries found", "info"); + return; + } + + const items = labeled.map((l) => ({ + value: l.id, + label: `[${l.label}]`, + description: l.preview || l.id.slice(0, 8), + })); + + const selectedId = await searchableSelect( + ctx, + "Clear label from entry", + items, + ); + + if (selectedId) { + pi.setLabel(selectedId, undefined); + ctx.ui.notify("Label cleared", "info"); + } + }, + }, + { + key: "g", + label: "Go to label", + description: "jump to a labeled entry", + action: async (ctx: ExtensionContext) => { + const labeled = collectLabeledEntries(ctx.sessionManager.getTree()); + + if (labeled.length === 0) { + ctx.ui.notify("No labeled entries found", "info"); + return; + } + + const items = labeled.map((l) => ({ + value: l.id, + label: `[${l.label}]`, + description: l.preview || l.id.slice(0, 8), + })); + + const selectedId = await searchableSelect( + ctx, + "Jump to labeled entry", + items, + ); + + if (selectedId) { + await tryNavigateTree(ctx, selectedId); + } + }, + }, + ], + }, + }; +} diff --git a/extensions/leader-key/session-actions.ts b/extensions/leader-key/session-actions.ts new file mode 100644 index 0000000..fe5aa94 --- /dev/null +++ b/extensions/leader-key/session-actions.ts @@ -0,0 +1,166 @@ +/** + * Session-related leader-key actions: session tree, session picker. + */ + +import type { + ExtensionAPI, + ExtensionContext, +} from "@mariozechner/pi-coding-agent"; +import { TreeSelectorComponent, SessionSelectorComponent, SessionManager } from "@mariozechner/pi-coding-agent"; +import type { TopLevelEntry } from "./types.js"; +import { tryNavigateTree, trySwitchSession } from "./context-helpers.js"; + +async function openSessionTree(pi: ExtensionAPI, ctx: ExtensionContext) { + const tree = ctx.sessionManager.getTree(); + const currentLeafId = ctx.sessionManager.getLeafId(); + + const selectedId = await ctx.ui.custom( + (tui, _theme, _kb, done) => { + const termRows = tui.terminal?.rows ?? 40; + const selector = new TreeSelectorComponent( + tree, + currentLeafId, + termRows, + (entryId) => done(entryId), + () => done(null), + (entryId, label) => pi.setLabel(entryId, label), + ); + return { + render: (w: number) => selector.render(w), + invalidate: () => selector.invalidate(), + handleInput: (data: string) => { + selector.handleInput(data); + tui.requestRender(); + }, + }; + }, + { + overlay: true, + overlayOptions: { + anchor: "center", + width: "90%", + minWidth: 60, + maxHeight: "85%", + }, + }, + ); + + if (selectedId && selectedId !== currentLeafId) { + await tryNavigateTree(ctx, selectedId); + } +} + +async function openSessionPicker(pi: ExtensionAPI, ctx: ExtensionContext) { + const currentCwd = ctx.cwd; + const currentSessionFilePath = ctx.sessionManager.getSessionFile(); + + const currentSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => { + return SessionManager.list(currentCwd, undefined, onProgress); + }; + const allSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => { + return SessionManager.listAll(onProgress); + }; + + const selectedPath = await ctx.ui.custom( + (tui, _theme, _kb, done) => { + const selector = new SessionSelectorComponent( + currentSessionsLoader, + allSessionsLoader, + (sessionPath) => done(sessionPath), + () => done(null), + () => done(null), + () => tui.requestRender(), + { + renameSession: async (sessionPath, currentName) => { + const name = (currentName ?? "").trim(); + const mgr = SessionManager.open(sessionPath); + mgr.setSessionName(name || undefined); + }, + showRenameHint: true, + }, + currentSessionFilePath, + ); + return { + render: (w: number) => selector.render(w), + invalidate: () => selector.invalidate(), + handleInput: (data: string) => { + selector.handleInput(data); + tui.requestRender(); + }, + }; + }, + { + overlay: true, + overlayOptions: { + anchor: "center", + width: "90%", + minWidth: 60, + maxHeight: "85%", + }, + }, + ); + + if (selectedPath) { + await trySwitchSession(ctx, selectedPath); + } +} + +export function buildSessionEntries(pi: ExtensionAPI): TopLevelEntry { + return { + type: "group", + group: { + key: "s", + label: "Session", + items: [ + { + key: "n", + label: "New session", + description: "start fresh", + action: (ctx) => { + ctx.ui.setEditorText("/new"); + setTimeout(() => process.stdin.emit("data", "\r"), 0); + }, + }, + { + key: "s", + label: "Resume session", + description: "pick & switch", + action: (ctx) => openSessionPicker(pi, ctx), + }, + { + key: "t", + label: "Session tree", + description: "navigate branches", + action: (ctx) => openSessionTree(pi, ctx), + }, + { + key: "f", + label: "Fork session", + description: "fork into cmux split pane", + action: (ctx) => { + ctx.ui.setEditorText("/split-fork"); + setTimeout(() => process.stdin.emit("data", "\r"), 0); + }, + }, + { + key: "c", + label: "Compact", + description: "LLM summary", + action: (ctx) => { + ctx.ui.setEditorText("/compact"); + setTimeout(() => process.stdin.emit("data", "\r"), 0); + }, + }, + { + key: "v", + label: "Compact with VCC", + description: "algorithmic summary", + action: (ctx) => { + ctx.ui.setEditorText("/pi-vcc"); + setTimeout(() => process.stdin.emit("data", "\r"), 0); + }, + }, + ], + }, + }; +} diff --git a/extensions/leader-key/types.ts b/extensions/leader-key/types.ts new file mode 100644 index 0000000..6ad206b --- /dev/null +++ b/extensions/leader-key/types.ts @@ -0,0 +1,23 @@ +/** + * Shared types for leader-key extension. + */ + +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; + +export interface ActionItem { + key: string; // single character shortcut + label: string; + description?: string; + action: (ctx: ExtensionContext) => void | Promise; +} + +export interface ActionGroup { + key: string; // chord key to open this group + label: string; + items: ActionItem[]; +} + +/** Top-level entry: either a group (chord → submenu) or a direct action */ +export type TopLevelEntry = + | { type: "group"; group: ActionGroup } + | { type: "action"; key: string; label: string; description?: string; action: (ctx: ExtensionContext) => void | Promise }; diff --git a/extensions/permissions/events.ts b/extensions/permissions/events.ts new file mode 100644 index 0000000..61dbe49 --- /dev/null +++ b/extensions/permissions/events.ts @@ -0,0 +1,13 @@ +export const PERMISSION_REQUEST_EVENT = "permissions:request"; +export const PERMISSION_RESOLVED_EVENT = "permissions:resolved"; + +export interface PermissionRequestPayload { + sessionId: string; + cwd: string; + toolName: string; + input: Record; +} + +export interface PermissionResolvedPayload extends PermissionRequestPayload { + response: "allow" | "reject"; +} diff --git a/extensions/permissions/permissions.ts b/extensions/permissions/permissions.ts index 715f90d..5f347d8 100644 --- a/extensions/permissions/permissions.ts +++ b/extensions/permissions/permissions.ts @@ -1,14 +1,14 @@ /** - * Permissions Extension — 4-mode system + * Permissions Extension — 3-mode system * * Modes: * yolo — All commands allowed without checks * safe — Permission rules active (ask for unknown bash commands) - * plan — Plannotator planning mode - * read-only — No writes, bash restricted to safe read-only commands + * read-only — No repo/home writes, built-in write/edit allowed only in /tmp, + * bash restricted to safe read-only commands * * Commands: - * /mode [yolo|safe|plan|read-only] — Switch mode + * /mode [yolo|safe|read-only] — Switch mode * * Settings: * ~/.pi/agent/permissions.json (global: mode + rules) @@ -19,18 +19,26 @@ * First match wins. See rules.ts to add new permissions. */ +import { resolve } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { BashRule, PermissionMode } from "./types.js"; import { ALL_MODES, MODE_DESCRIPTIONS } from "./types.js"; import { BUILTIN_RULES } from "./rules.js"; import { CD_PREFIX_RE, findMatchingRule, isReadSafeBash } from "./matching.js"; +import { + PERMISSION_REQUEST_EVENT, + PERMISSION_RESOLVED_EVENT, + type PermissionRequestPayload, +} from "./events.js"; import { loadProjectRules, loadSettings, normalizeMode, saveSettings } from "./settings.js"; -// Re-export for consumers (e.g. custom-footer) export type { PermissionMode } from "./types.js"; -// ── The Single Permission Gate ───────────────────────────────────────── +function isTmpPath(path: string, cwd: string): boolean { + const absolutePath = resolve(cwd, path); + return absolutePath === "/tmp" || absolutePath.startsWith("/tmp/"); +} /** * Evaluate whether a tool call should be allowed, blocked, or needs confirmation. @@ -42,11 +50,19 @@ async function evaluateToolCall( input: Record, mode: PermissionMode, ctx: ExtensionContext, + notifyPermission?: { + onRequest: (payload: PermissionRequestPayload) => void; + onResolved: (payload: PermissionRequestPayload & { response: "allow" | "reject" }) => void; + }, ): Promise { - // ── Read-only: block writes, restrict bash to safe commands ──── if (mode === "read-only") { if (toolName === "write" || toolName === "edit") { - return { block: true, reason: "Read-only mode: file writes are blocked. Use /mode to change." }; + const path = input.path as string; + if (isTmpPath(path, ctx.cwd)) return undefined; + return { + block: true, + reason: `Read-only mode: file writes are blocked outside /tmp. Use /mode to change.\nPath: ${path}`, + }; } if (toolName === "bash") { const command = input.command as string; @@ -60,16 +76,13 @@ async function evaluateToolCall( return undefined; } - // ── Plan / yolo: no restrictions ────────────────────────────── - if (mode === "plan" || mode === "yolo") return undefined; + if (mode === "yolo") return undefined; - // ── Safe mode: evaluate rules (bash only) ───────────────────── if (toolName !== "bash") return undefined; const command = input.command as string; const stripped = command.trim().replace(CD_PREFIX_RE, "").trim(); - // Merge: project rules → global user rules → built-in rules const settings = loadSettings(); const projectRules = loadProjectRules(ctx.cwd); const allRules: BashRule[] = [...projectRules, ...(settings.rules ?? []), ...BUILTIN_RULES]; @@ -86,61 +99,40 @@ async function evaluateToolCall( if (!ctx.hasUI) { return { block: true, reason: "Command requires confirmation (no UI available)" }; } + + const payload: PermissionRequestPayload = { + sessionId: ctx.sessionManager.getSessionId(), + cwd: ctx.cwd, + toolName, + input, + }; + + notifyPermission?.onRequest(payload); + const choice = await ctx.ui.select( `⚠️ Permission required:\n\n ${command}\n\nAllow? (Use /mode to switch modes)`, ["Yes", "No"], ); if (choice !== "Yes") { + notifyPermission?.onResolved({ ...payload, response: "reject" }); ctx.abort(); return { block: true, reason: "Blocked by user" }; } + notifyPermission?.onResolved({ ...payload, response: "allow" }); return undefined; } } } -// ── Extension ────────────────────────────────────────────────────────── - let permissionMode: PermissionMode = normalizeMode(loadSettings().mode); export default function (pi: ExtensionAPI) { - /** Whether we activated plannotator (so we know to deactivate on mode switch). */ - let plannotatorActivatedByUs = false; - - function triggerPlannotator(ctx: ExtensionContext, entering: boolean): void { - // When entering plan mode, pass a timestamped path so plannotator - // skips the interactive file-name prompt. When exiting (toggle off), - // no path is needed — plannotator just disables. - if (entering) { - const now = new Date(); - const pad = (n: number, w = 2) => String(n).padStart(w, "0"); - const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; - ctx.ui.setEditorText(`/plannotator .plan/${ts}.md`); - } else { - ctx.ui.setEditorText("/plannotator"); - } - setTimeout(() => process.stdin.emit("data", "\r"), 0); - } - function setMode(mode: PermissionMode, ctx: ExtensionContext): void { - const previousMode = permissionMode; permissionMode = mode; const current = loadSettings(); saveSettings({ ...current, mode }); pi.events.emit("mode:change", mode); ctx.ui.notify(`Mode: ${mode.toUpperCase()} — ${MODE_DESCRIPTIONS[mode]}`); - - // Auto-activate plannotator when entering plan mode - if (mode === "plan" && previousMode !== "plan") { - plannotatorActivatedByUs = true; - triggerPlannotator(ctx, true); - } - - // Auto-deactivate plannotator when leaving plan mode (if we activated it) - if (mode !== "plan" && previousMode === "plan" && plannotatorActivatedByUs) { - plannotatorActivatedByUs = false; - triggerPlannotator(ctx, false); - } } async function modeHandler(args: string | undefined, ctx: ExtensionContext): Promise { @@ -160,10 +152,8 @@ export default function (pi: ExtensionAPI) { if (selected) setMode(selected, ctx); } - // ── Commands & Shortcuts ── - pi.registerCommand("mode", { - description: "Switch permission mode (yolo/safe/plan/read-only)", + description: "Switch permission mode (yolo/safe/read-only)", getArgumentCompletions: (prefix: string) => { const items = ALL_MODES.map((m) => ({ value: m, @@ -180,15 +170,18 @@ export default function (pi: ExtensionAPI) { handler: async (args, ctx) => modeHandler(args, ctx), }); - // ── Session Start ── - pi.on("session_start", async (_event, _ctx) => { pi.events.emit("mode:change", permissionMode); }); - // ── Tool Call Interception (single gate) ── - pi.on("tool_call", async (event, ctx) => { - return evaluateToolCall(event.toolName, event.input, permissionMode, ctx); + return evaluateToolCall(event.toolName, event.input, permissionMode, ctx, { + onRequest: (payload) => { + pi.events.emit(PERMISSION_REQUEST_EVENT, payload); + }, + onResolved: (payload) => { + pi.events.emit(PERMISSION_RESOLVED_EVENT, payload); + }, + }); }); } diff --git a/extensions/permissions/settings.ts b/extensions/permissions/settings.ts index b52ef8f..3afb4ba 100644 --- a/extensions/permissions/settings.ts +++ b/extensions/permissions/settings.ts @@ -45,6 +45,7 @@ export function loadProjectRules(cwd: string): BashRule[] { export function normalizeMode(raw: string | undefined): PermissionMode { if (raw === "enabled") return "safe"; if (raw === "read") return "read-only"; // backward compat - if (raw === "yolo" || raw === "safe" || raw === "plan" || raw === "read-only") return raw; + if (raw === "plan") return "safe"; + if (raw === "yolo" || raw === "safe" || raw === "read-only") return raw; return "safe"; } diff --git a/extensions/permissions/types.ts b/extensions/permissions/types.ts index d5a160e..ca75d53 100644 --- a/extensions/permissions/types.ts +++ b/extensions/permissions/types.ts @@ -1,14 +1,13 @@ /** Shared types for the permissions extension. */ -export type PermissionMode = "yolo" | "safe" | "plan" | "read-only"; +export type PermissionMode = "yolo" | "safe" | "read-only"; -export const ALL_MODES: PermissionMode[] = ["yolo", "safe", "plan", "read-only"]; +export const ALL_MODES: PermissionMode[] = ["yolo", "safe", "read-only"]; export const MODE_DESCRIPTIONS: Record = { yolo: "All commands allowed, no checks", safe: "Permission rules active", - plan: "Plannotator planning mode", - "read-only": "Read-only, no writes allowed", + "read-only": "Read-only, no writes except /tmp", }; /** diff --git a/extensions/pi-sem/README.md b/extensions/pi-sem/README.md new file mode 100644 index 0000000..e3188d1 --- /dev/null +++ b/extensions/pi-sem/README.md @@ -0,0 +1,76 @@ +# pi-sem + +Semantic Git tooling for Pi, powered by [`sem`](https://github.com/Ataraxy-Labs/sem). + +The extension exposes entity-aware tools to the model instead of making it reason over raw line hunks only. + +## Included tools + +- `sem_diff` - entity-level diff for working tree, staged changes, commits, or ranges +- `sem_impact` - dependency / blast-radius analysis for an entity +- `sem_context` - token-budgeted semantic context for a focused entity +- `sem_log` - entity history across git commits +- `sem_entities` - structural inventory of a file +- `sem_blame` - entity-level blame for a file +- `sem_eval` - compares `sem diff` against raw `git diff` for coverage and prompt footprint + +## Installation notes + +This repo declares `@ataraxy-labs/sem` as an optional dependency, so `npm install` should fetch the wrapper binary automatically on supported platforms. + +Fallback options: + +```bash +npm install @ataraxy-labs/sem +# or +brew install sem-cli +# or +cargo install --git https://github.com/Ataraxy-Labs/sem sem-cli +``` + +## Evaluation workflow + +Run the local benchmark helper to compare semantic diff coverage and size versus raw git diff: + +```bash +npm run sem:evaluate -- --staged +npm run sem:evaluate -- --from origin/main --to HEAD +npm run sem:evaluate -- --commit HEAD +``` + +Useful flags: + +```bash +npm run sem:evaluate -- --staged --no-impact +npm run sem:evaluate -- --from origin/main --to HEAD --file-ext .ts --file-ext .md +npm run sem:evaluate -- --format json +``` + +## What to look at + +The evaluator reports: + +- **coverage** - how many git-changed files also show up in `sem diff` +- **payload size** - bytes / lines / rough token estimate for raw `git diff` vs `sem diff --format json` +- **semantic summary** - number of entities, change types, and top changed entities +- **impact samples** - sample `sem impact --tests` lookups for a few changed entities + +Important: our first local runs showed that `sem diff --format json` is **not always smaller** than raw `git diff`, especially on very large or lockfile-heavy diffs. The upside is better entity structure and impact/context workflows, not guaranteed token compression on every selection. + +## Suggested Pi usage + +Good prompts once the extension is loaded: + +- “What changed in this branch? Use sem.” +- “Use `sem_eval` to compare semantic diff vs git diff on the current changes.” +- “What tests are affected by changes to `buildReport`? Use `sem_impact`.” +- “Give me focused context for `buildReport` with a 4000 token budget using `sem_context`." + +Recommended default posture: + +- prefer **`sem_context`** for one suspicious function/class +- prefer **`sem_impact`** for blast radius and test selection +- use **`sem_diff`** once for overview / counts / review framing +- keep **raw `git diff` and file reads** for exact line-level evidence + +If the review extension is loaded too, `/review` now adds this semantic workflow directly into its review prompt. diff --git a/extensions/pi-sem/bin/sem-eval.mjs b/extensions/pi-sem/bin/sem-eval.mjs new file mode 100755 index 0000000..ec94329 --- /dev/null +++ b/extensions/pi-sem/bin/sem-eval.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { + SEM_INSTALL_HINT, + buildEvaluationReport, + buildGitDiffArgs, + buildGitNameOnlyArgs, + buildImpactArgs, + buildSemDiffArgs, + formatEvaluationReport, + normalizeSelection, + parseSemJsonOutput, + pickImpactTargets, + resolveSemInvocation, +} from "../core.mjs"; + +function printHelp() { + process.stdout.write( + [ + "Usage: sem-eval [options]", + "", + "Compare sem diff with raw git diff for the same selection.", + "", + "Options:", + " --staged Evaluate staged changes", + " --commit Evaluate a specific commit", + " --from --to Evaluate a commit range", + " --file-ext Filter diff by extension (repeatable)", + " --no-impact Skip sem impact samples", + " --impact-limit Number of entities for impact samples (default: 3)", + " --format Output format (default: markdown)", + " --help Show this help", + ].join("\n") + "\n", + ); +} + +function parseArgs(argv) { + const options = { + staged: false, + commit: undefined, + from: undefined, + to: undefined, + fileExts: [], + includeImpact: true, + impactLimit: 3, + format: "markdown", + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--staged") { + options.staged = true; + continue; + } + if (arg === "--commit") { + options.commit = argv[++i]; + continue; + } + if (arg === "--from") { + options.from = argv[++i]; + continue; + } + if (arg === "--to") { + options.to = argv[++i]; + continue; + } + if (arg === "--file-ext") { + options.fileExts.push(argv[++i]); + continue; + } + if (arg === "--no-impact") { + options.includeImpact = false; + continue; + } + if (arg === "--impact-limit") { + options.impactLimit = Number(argv[++i]); + continue; + } + if (arg === "--format") { + options.format = argv[++i] ?? "markdown"; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error) { + const message = result.error.message || String(result.error); + if (message.includes("ENOENT") || message.includes("not found")) { + throw new Error(["Required command not found.", "", SEM_INSTALL_HINT].join("\n")); + } + throw result.error; + } + + if (result.status !== 0) { + throw new Error( + [ + `Command failed (exit ${result.status}).`, + result.stderr?.trim() || result.stdout?.trim() || "No output.", + ].join("\n"), + ); + } + + return result.stdout ?? ""; +} + +try { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + process.exit(0); + } + + const selection = normalizeSelection(options); + const semInvocation = resolveSemInvocation(process.cwd()); + const semArgs = buildSemDiffArgs(options); + const gitDiffArgs = buildGitDiffArgs(options); + const gitNameArgs = buildGitNameOnlyArgs(options); + + const semText = run(semInvocation.command, [...semInvocation.argsPrefix, ...semArgs]); + const semJson = parseSemJsonOutput("sem_eval", semText); + const gitDiffText = run("git", gitDiffArgs); + const gitNamesText = run("git", gitNameArgs); + const gitFiles = gitNamesText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const impactResults = []; + if (options.includeImpact) { + const targets = pickImpactTargets(semJson, Math.max(1, Math.floor(options.impactLimit || 3))); + for (const target of targets) { + const impactArgs = buildImpactArgs({ entity: target.entity, file: target.file, scope: "tests" }); + try { + const impactText = run(semInvocation.command, [...semInvocation.argsPrefix, ...impactArgs]); + impactResults.push({ + entity: target.entity, + file: target.file, + result: parseSemJsonOutput("sem impact", impactText), + }); + } catch (error) { + impactResults.push({ + entity: target.entity, + file: target.file, + result: { error: error instanceof Error ? error.message : String(error) }, + }); + } + } + } + + const report = buildEvaluationReport({ + selection, + gitDiffText, + gitFiles, + semDiffText: semText, + semDiffJson: semJson, + impactResults, + }); + + if (options.format === "json") { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } else { + process.stdout.write(`${formatEvaluationReport(report)}\n`); + } +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +} diff --git a/extensions/pi-sem/core.mjs b/extensions/pi-sem/core.mjs new file mode 100644 index 0000000..d8f8a7b --- /dev/null +++ b/extensions/pi-sem/core.mjs @@ -0,0 +1,458 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const THIS_DIR = dirname(fileURLToPath(import.meta.url)); + +export const SEM_INSTALL_HINT = [ + "Install sem via one of these options:", + "- npm install @ataraxy-labs/sem", + "- brew install sem-cli", + "- cargo install --git https://github.com/Ataraxy-Labs/sem sem-cli", +].join("\n"); + +const ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g; + +function cleanString(value) { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeExtensions(fileExts) { + if (!Array.isArray(fileExts)) return []; + const seen = new Set(); + const normalized = []; + for (const value of fileExts) { + const cleaned = cleanString(value); + if (!cleaned) continue; + const ext = cleaned.startsWith(".") ? cleaned : `.${cleaned}`; + if (seen.has(ext)) continue; + seen.add(ext); + normalized.push(ext); + } + return normalized; +} + +function baseSelection(selection = {}) { + return { + staged: selection.staged === true, + commit: cleanString(selection.commit), + from: cleanString(selection.from), + to: cleanString(selection.to), + fileExts: normalizeExtensions(selection.fileExts), + }; +} + +export function normalizeSelection(selection = {}) { + const normalized = baseSelection(selection); + return { + ...normalized, + label: describeSelection(normalized), + }; +} + +export function describeSelection(selection = {}) { + const normalized = baseSelection(selection); + if (normalized.staged) return "staged changes"; + if (normalized.commit) return `commit ${normalized.commit}`; + if (normalized.from || normalized.to) { + const from = normalized.from ?? ""; + const to = normalized.to ?? ""; + return `range ${from}..${to}`; + } + return "working tree changes"; +} + +export function validateDiffSelection(selection = {}) { + const normalized = baseSelection(selection); + const modes = [ + normalized.staged ? "staged" : null, + normalized.commit ? "commit" : null, + normalized.from || normalized.to ? "range" : null, + ].filter(Boolean); + + if (modes.length > 1) { + return { + ok: false, + error: "Use only one diff selector: staged, commit, or from/to range.", + normalized, + }; + } + + if ((normalized.from && !normalized.to) || (!normalized.from && normalized.to)) { + return { + ok: false, + error: "Range comparisons require both from and to refs.", + normalized, + }; + } + + return { ok: true, normalized }; +} + +export function gitPathspecsFromExtensions(fileExts = []) { + const normalized = normalizeExtensions(fileExts); + if (normalized.length === 0) return []; + return ["--", ...normalized.map((ext) => `:(glob)**/*${ext}`)]; +} + +export function buildSemDiffArgs(selection = {}) { + const validation = validateDiffSelection(selection); + if (!validation.ok) throw new Error(validation.error); + const { normalized } = validation; + const args = ["diff"]; + if (normalized.staged) args.push("--staged"); + if (normalized.commit) args.push("--commit", normalized.commit); + if (normalized.from && normalized.to) args.push("--from", normalized.from, "--to", normalized.to); + if (normalized.fileExts.length > 0) args.push("--file-exts", ...normalized.fileExts); + args.push("--format", "json"); + return args; +} + +export function buildGitDiffArgs(selection = {}) { + const validation = validateDiffSelection(selection); + if (!validation.ok) throw new Error(validation.error); + const { normalized } = validation; + const pathspecs = gitPathspecsFromExtensions(normalized.fileExts); + + if (normalized.commit) return ["show", "--format=", "--unified=3", normalized.commit, ...pathspecs]; + if (normalized.staged) return ["diff", "--staged", "--unified=3", ...pathspecs]; + if (normalized.from && normalized.to) return ["diff", "--unified=3", normalized.from, normalized.to, ...pathspecs]; + return ["diff", "--unified=3", ...pathspecs]; +} + +export function buildGitNameOnlyArgs(selection = {}) { + const validation = validateDiffSelection(selection); + if (!validation.ok) throw new Error(validation.error); + const { normalized } = validation; + const pathspecs = gitPathspecsFromExtensions(normalized.fileExts); + + if (normalized.commit) return ["show", "--format=", "--name-only", normalized.commit, ...pathspecs]; + if (normalized.staged) return ["diff", "--staged", "--name-only", ...pathspecs]; + if (normalized.from && normalized.to) return ["diff", "--name-only", normalized.from, normalized.to, ...pathspecs]; + return ["diff", "--name-only", ...pathspecs]; +} + +export function buildImpactArgs({ entity, file, scope = "all" }) { + const entityName = cleanString(entity); + if (!entityName) throw new Error("sem impact requires an entity name."); + const args = ["impact", entityName]; + if (scope === "deps") args.push("--deps"); + if (scope === "dependents") args.push("--dependents"); + if (scope === "tests") args.push("--tests"); + if (cleanString(file)) args.push("--file", cleanString(file)); + args.push("--json"); + return args; +} + +export function buildContextArgs({ entity, file, budget }) { + const entityName = cleanString(entity); + if (!entityName) throw new Error("sem context requires an entity name."); + const args = ["context", entityName]; + if (cleanString(file)) args.push("--file", cleanString(file)); + if (Number.isFinite(budget) && Number(budget) > 0) args.push("--budget", String(Math.floor(Number(budget)))); + return args; +} + +export function buildLogArgs({ entity, file, limit, verbose }) { + const entityName = cleanString(entity); + if (!entityName) throw new Error("sem log requires an entity name."); + const args = ["log", entityName]; + if (cleanString(file)) args.push("--file", cleanString(file)); + if (Number.isFinite(limit) && Number(limit) > 0) args.push("--limit", String(Math.floor(Number(limit)))); + if (verbose) args.push("-v"); + args.push("--json"); + return args; +} + +export function buildEntitiesArgs({ file }) { + const targetFile = cleanString(file); + if (!targetFile) throw new Error("sem entities requires a file path."); + return ["entities", targetFile, "--json"]; +} + +export function buildBlameArgs({ file }) { + const targetFile = cleanString(file); + if (!targetFile) throw new Error("sem blame requires a file path."); + return ["blame", targetFile, "--json"]; +} + +export function resolveSemInvocation(cwd = process.cwd()) { + const candidates = []; + + try { + const pkgPath = require.resolve("@ataraxy-labs/sem/package.json", { paths: [cwd, THIS_DIR] }); + const pkgDir = dirname(pkgPath); + const wrapperPath = join(pkgDir, "bin", "sem.js"); + if (existsSync(wrapperPath)) { + candidates.push({ command: process.execPath, argsPrefix: [wrapperPath], source: "@ataraxy-labs/sem" }); + } + } catch { + // Ignore and fall back. + } + + const localBin = join(cwd, "node_modules", ".bin", process.platform === "win32" ? "sem.cmd" : "sem"); + if (existsSync(localBin)) { + candidates.push({ command: localBin, argsPrefix: [], source: "project node_modules/.bin" }); + } + + candidates.push({ command: process.platform === "win32" ? "sem.exe" : "sem", argsPrefix: [], source: "PATH" }); + + return candidates[0]; +} + +export function stripAnsi(text = "") { + return String(text).replace(ANSI_PATTERN, ""); +} + +export function parseSemJsonOutput(toolName, text) { + const cleaned = stripAnsi(text).trim(); + if ((toolName === "sem diff" || toolName === "sem_eval") && (cleaned.length === 0 || /^No changes detected\.?$/i.test(cleaned))) { + return { + summary: { fileCount: 0, added: 0, modified: 0, deleted: 0, total: 0 }, + changes: [], + }; + } + return JSON.parse(cleaned); +} + +export function estimateTokens(text = "") { + return Math.max(0, Math.ceil(Buffer.byteLength(String(text), "utf8") / 4)); +} + +export function countLines(text = "") { + if (text.length === 0) return 0; + return String(text).split(/\r?\n/).length; +} + +export function sizeBytes(text = "") { + return Buffer.byteLength(String(text), "utf8"); +} + +export function formatPercent(numerator, denominator) { + if (!denominator) return "0%"; + return `${Math.round((numerator / denominator) * 100)}%`; +} + +export function summarizeSemDiffPayload(payload) { + const summary = typeof payload === "object" && payload && typeof payload.summary === "object" && payload.summary ? payload.summary : {}; + const changes = Array.isArray(payload?.changes) ? payload.changes : []; + const files = new Map(); + const entityTypes = new Map(); + + for (const change of changes) { + const filePath = cleanString(change?.filePath) ?? ""; + if (!files.has(filePath)) files.set(filePath, []); + files.get(filePath).push(change); + + const entityType = cleanString(change?.entityType) ?? "unknown"; + entityTypes.set(entityType, (entityTypes.get(entityType) ?? 0) + 1); + } + + const changedFiles = [...files.keys()].sort(); + const topChanges = changes.slice(0, 12).map((change) => ({ + changeType: cleanString(change?.changeType) ?? "changed", + entityType: cleanString(change?.entityType) ?? "entity", + entityName: cleanString(change?.entityName) ?? "", + filePath: cleanString(change?.filePath) ?? "", + })); + + return { + summary: { + fileCount: Number(summary.fileCount ?? changedFiles.length) || changedFiles.length, + added: Number(summary.added ?? 0) || 0, + modified: Number(summary.modified ?? 0) || 0, + deleted: Number(summary.deleted ?? 0) || 0, + total: Number(summary.total ?? changes.length) || changes.length, + }, + changes, + changedFiles, + changesByFile: Object.fromEntries([...files.entries()].map(([file, value]) => [file, value.length])), + entityTypeCounts: Object.fromEntries([...entityTypes.entries()].sort((a, b) => a[0].localeCompare(b[0]))), + topChanges, + }; +} + +export function compareCoverage(gitFiles = [], semFiles = []) { + const gitSet = new Set(gitFiles.map((file) => cleanString(file)).filter(Boolean)); + const semSet = new Set(semFiles.map((file) => cleanString(file)).filter(Boolean)); + const covered = [...gitSet].filter((file) => semSet.has(file)).sort(); + const missing = [...gitSet].filter((file) => !semSet.has(file)).sort(); + const extra = [...semSet].filter((file) => !gitSet.has(file)).sort(); + + return { + gitFiles: [...gitSet].sort(), + semFiles: [...semSet].sort(), + covered, + missing, + extra, + coveragePercent: formatPercent(covered.length, gitSet.size), + }; +} + +export function pickImpactTargets(payload, limit = 3) { + const changes = Array.isArray(payload?.changes) ? payload.changes : []; + const targets = []; + const seen = new Set(); + + for (const change of changes) { + const entityName = cleanString(change?.entityName); + const filePath = cleanString(change?.filePath); + const changeType = cleanString(change?.changeType); + if (!entityName || !filePath) continue; + if (changeType === "deleted") continue; + const key = `${filePath}::${entityName}`; + if (seen.has(key)) continue; + seen.add(key); + targets.push({ + entity: entityName, + file: filePath, + entityType: cleanString(change?.entityType) ?? "entity", + changeType: changeType ?? "modified", + }); + if (targets.length >= limit) break; + } + + return targets; +} + +function countNestedItems(value) { + if (Array.isArray(value)) return value.length; + if (!value || typeof value !== "object") return 0; + + for (const key of ["tests", "affectedTests", "impactedTests", "dependents", "affected", "items"]) { + if (Array.isArray(value[key])) return value[key].length; + } + + let total = 0; + for (const nested of Object.values(value)) { + if (Array.isArray(nested)) total += nested.length; + } + return total; +} + +export function summarizeImpactResult(result) { + if (!result || typeof result !== "object") { + return { inferredCount: 0, keys: [], preview: result }; + } + + return { + inferredCount: countNestedItems(result), + keys: Object.keys(result).sort(), + preview: result, + }; +} + +export function buildEvaluationReport({ + selection = {}, + gitDiffText = "", + gitFiles = [], + semDiffText = "", + semDiffJson = {}, + impactResults = [], +}) { + const normalized = normalizeSelection(selection); + const semSummary = summarizeSemDiffPayload(semDiffJson); + const coverage = compareCoverage(gitFiles, semSummary.changedFiles); + + return { + selection: normalized, + payload: { + gitDiff: { + bytes: sizeBytes(gitDiffText), + lines: countLines(gitDiffText), + estimatedTokens: estimateTokens(gitDiffText), + }, + semDiff: { + bytes: sizeBytes(semDiffText), + lines: countLines(semDiffText), + estimatedTokens: estimateTokens(semDiffText), + }, + }, + compression: { + bytesSaved: sizeBytes(gitDiffText) - sizeBytes(semDiffText), + tokensSaved: estimateTokens(gitDiffText) - estimateTokens(semDiffText), + tokenRatio: estimateTokens(gitDiffText) === 0 ? null : Number((estimateTokens(semDiffText) / estimateTokens(gitDiffText)).toFixed(2)), + }, + coverage, + sem: semSummary, + impacts: impactResults.map((impact) => ({ + ...impact, + summary: summarizeImpactResult(impact.result), + })), + }; +} + +export function formatCompactChangeList(changes = [], limit = 8) { + const slice = changes.slice(0, limit); + if (slice.length === 0) return ["- no changed entities reported"]; + const lines = slice.map((change) => { + return `- ${change.changeType} ${change.entityType} ${change.entityName} — ${change.filePath}`; + }); + if (changes.length > slice.length) { + lines.push(`- … ${changes.length - slice.length} more entities`); + } + return lines; +} + +export function formatEvaluationReport(report) { + const lines = []; + lines.push("# sem evaluation"); + lines.push(""); + lines.push(`- selection: ${report.selection.label}`); + if (report.selection.fileExts.length > 0) { + lines.push(`- file filters: ${report.selection.fileExts.join(", ")}`); + } + lines.push(""); + lines.push("## coverage"); + lines.push(`- git changed files: ${report.coverage.gitFiles.length}`); + lines.push(`- sem files with changed entities: ${report.coverage.semFiles.length}`); + lines.push(`- semantic coverage: ${report.coverage.covered.length}/${report.coverage.gitFiles.length} (${report.coverage.coveragePercent})`); + if (report.coverage.missing.length > 0) { + lines.push(`- missing from sem coverage: ${report.coverage.missing.join(", ")}`); + } + if (report.coverage.extra.length > 0) { + lines.push(`- extra sem-only files: ${report.coverage.extra.join(", ")}`); + } + lines.push(""); + lines.push("## payload size"); + lines.push(`- git diff: ${report.payload.gitDiff.lines} lines, ${report.payload.gitDiff.bytes} bytes, ~${report.payload.gitDiff.estimatedTokens} tokens`); + lines.push(`- sem diff json: ${report.payload.semDiff.lines} lines, ${report.payload.semDiff.bytes} bytes, ~${report.payload.semDiff.estimatedTokens} tokens`); + if (report.compression.tokenRatio !== null) { + lines.push(`- sem/git token ratio: ${report.compression.tokenRatio}`); + } + if (report.compression.tokensSaved > 0) { + lines.push(`- estimated tokens saved vs git diff: ${report.compression.tokensSaved}`); + } else if (report.compression.tokensSaved < 0) { + lines.push(`- estimated extra tokens vs git diff: ${Math.abs(report.compression.tokensSaved)}`); + } else { + lines.push("- estimated token delta vs git diff: 0"); + } + if (report.compression.tokenRatio !== null && report.compression.tokenRatio > 1) { + lines.push("- note: on this selection, sem JSON is larger than raw git diff. sem is still useful for entity reasoning, impact analysis, and targeted context, but not always for raw token compression."); + } + lines.push(""); + lines.push("## semantic summary"); + lines.push(`- entities changed: ${report.sem.summary.total} (added ${report.sem.summary.added}, modified ${report.sem.summary.modified}, deleted ${report.sem.summary.deleted})`); + lines.push(`- files with entity changes: ${report.sem.summary.fileCount}`); + const entityTypes = Object.entries(report.sem.entityTypeCounts); + if (entityTypes.length > 0) { + lines.push(`- entity types: ${entityTypes.map(([type, count]) => `${type}=${count}`).join(", ")}`); + } + lines.push(""); + lines.push("### top changed entities"); + lines.push(...formatCompactChangeList(report.sem.topChanges, 10)); + + if (report.impacts.length > 0) { + lines.push(""); + lines.push("## impact samples"); + for (const impact of report.impacts) { + lines.push(`- ${impact.entity} (${impact.file}): inferred nested items=${impact.summary.inferredCount}, keys=${impact.summary.keys.join(", ") || "n/a"}`); + } + } + + return lines.join("\n"); +} diff --git a/extensions/pi-sem/core.test.mjs b/extensions/pi-sem/core.test.mjs new file mode 100644 index 0000000..87670e4 --- /dev/null +++ b/extensions/pi-sem/core.test.mjs @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildEvaluationReport, + buildGitDiffArgs, + buildSemDiffArgs, + compareCoverage, + formatEvaluationReport, + parseSemJsonOutput, + pickImpactTargets, + summarizeSemDiffPayload, +} from "./core.mjs"; + +test("buildSemDiffArgs builds a range diff with extension filters", () => { + const args = buildSemDiffArgs({ from: "origin/main", to: "HEAD", fileExts: ["ts", ".md"] }); + assert.deepEqual(args, [ + "diff", + "--from", + "origin/main", + "--to", + "HEAD", + "--file-exts", + ".ts", + ".md", + "--format", + "json", + ]); +}); + +test("buildGitDiffArgs includes pathspecs for extension filtering", () => { + const args = buildGitDiffArgs({ staged: true, fileExts: ["ts"] }); + assert.deepEqual(args, ["diff", "--staged", "--unified=3", "--", ":(glob)**/*.ts"]); +}); + +test("parseSemJsonOutput normalizes empty sem diff output", () => { + const payload = parseSemJsonOutput("sem diff", "\u001b[2mNo changes detected.\u001b[0m"); + assert.deepEqual(payload, { + summary: { fileCount: 0, added: 0, modified: 0, deleted: 0, total: 0 }, + changes: [], + }); +}); + +test("compareCoverage reports missing and extra files", () => { + const coverage = compareCoverage(["a.ts", "b.ts"], ["b.ts", "c.ts"]); + assert.deepEqual(coverage.covered, ["b.ts"]); + assert.deepEqual(coverage.missing, ["a.ts"]); + assert.deepEqual(coverage.extra, ["c.ts"]); + assert.equal(coverage.coveragePercent, "50%"); +}); + +test("evaluation report formats semantic summary and impact samples", () => { + const semDiffJson = { + summary: { fileCount: 2, added: 1, modified: 1, deleted: 0, total: 2 }, + changes: [ + { + changeType: "modified", + entityType: "function", + entityName: "buildReport", + filePath: "src/report.ts", + }, + { + changeType: "added", + entityType: "class", + entityName: "SemRunner", + filePath: "src/runner.ts", + }, + ], + }; + + assert.deepEqual(pickImpactTargets(semDiffJson, 5), [ + { entity: "buildReport", file: "src/report.ts", entityType: "function", changeType: "modified" }, + { entity: "SemRunner", file: "src/runner.ts", entityType: "class", changeType: "added" }, + ]); + + const report = buildEvaluationReport({ + selection: { from: "origin/main", to: "HEAD" }, + gitDiffText: "diff --git a/src/report.ts b/src/report.ts\n+foo\n", + gitFiles: ["src/report.ts", "src/runner.ts", "README.md"], + semDiffText: JSON.stringify(semDiffJson, null, 2), + semDiffJson, + impactResults: [ + { + entity: "buildReport", + file: "src/report.ts", + result: { affectedTests: ["report.spec.ts", "runner.spec.ts"] }, + }, + ], + }); + + const formatted = formatEvaluationReport(report); + assert.match(formatted, /semantic coverage: 2\/3 \(67%\)/); + assert.match(formatted, /entities changed: 2 \(added 1, modified 1, deleted 0\)/); + assert.match(formatted, /buildReport/); + assert.match(formatted, /impact samples/); + + const summary = summarizeSemDiffPayload(semDiffJson); + assert.equal(summary.summary.total, 2); + assert.equal(summary.changesByFile["src/report.ts"], 1); + assert.equal(summary.entityTypeCounts.function, 1); +}); diff --git a/extensions/pi-sem/index.ts b/extensions/pi-sem/index.ts new file mode 100644 index 0000000..6ea7a5b --- /dev/null +++ b/extensions/pi-sem/index.ts @@ -0,0 +1,499 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + truncateHead, +} from "@mariozechner/pi-coding-agent"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import { mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SEM_INSTALL_HINT, + buildBlameArgs, + buildContextArgs, + buildEntitiesArgs, + buildEvaluationReport, + buildImpactArgs, + buildLogArgs, + buildSemDiffArgs, + compareCoverage, + describeSelection, + formatEvaluationReport, + normalizeSelection, + pickImpactTargets, + resolveSemInvocation, + summarizeSemDiffPayload, + buildGitDiffArgs, + buildGitNameOnlyArgs, + parseSemJsonOutput, +} from "./core.mjs"; + +type ToolResult = { + content: Array<{ type: "text"; text: string }>; + details: Record; +}; + +type ExecResult = { + stdout: string; + stderr: string; + code: number; +}; + +async function writeTruncatedOutput(prefix: string, content: string): Promise { + const dir = join(tmpdir(), `pi-sem-${Date.now()}`); + await mkdir(dir, { recursive: true }); + const file = join(dir, `${prefix}.txt`); + await writeFile(file, content, "utf8"); + return file; +} + +async function truncateToolText(prefix: string, content: string): Promise<{ text: string; tempFile?: string }> { + const truncation = truncateHead(content, { + maxBytes: DEFAULT_MAX_BYTES, + maxLines: DEFAULT_MAX_LINES, + }); + if (!truncation.truncated) return { text: truncation.content }; + + const tempFile = await writeTruncatedOutput(prefix, content); + const notice = [ + "", + `[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`, + `Full output saved to: ${tempFile}]`, + ].join("\n"); + + return { + text: `${truncation.content}${notice}`, + tempFile, + }; +} + +function formatCommand(invocation: { command: string; argsPrefix: string[] }, args: string[]): string { + return [invocation.command, ...invocation.argsPrefix, ...args].join(" "); +} + +async function runSem(pi: ExtensionAPI, cwd: string, args: string[], signal?: AbortSignal): Promise { + const invocation = resolveSemInvocation(cwd); + try { + const result = await pi.exec(invocation.command, [...invocation.argsPrefix, ...args], { signal }); + if (result.code !== 0) { + const stderr = result.stderr?.trim(); + const stdout = result.stdout?.trim(); + throw new Error( + [ + `sem command failed (exit ${result.code}).`, + stderr || stdout || "No output from sem.", + "", + SEM_INSTALL_HINT, + ].join("\n"), + ); + } + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + code: result.code, + invocation, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("ENOENT") || message.includes("not found")) { + throw new Error(["sem CLI not found.", "", SEM_INSTALL_HINT].join("\n")); + } + throw error; + } +} + +async function runGit(pi: ExtensionAPI, cwd: string, args: string[], signal?: AbortSignal): Promise { + const result = await pi.exec("git", args, { signal }); + if (result.code !== 0) { + throw new Error( + [ + `git command failed (exit ${result.code}).`, + result.stderr?.trim() || result.stdout?.trim() || "No output from git.", + ].join("\n"), + ); + } + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + code: result.code, + }; +} + +function parseJson(toolName: string, text: string): unknown { + try { + return parseSemJsonOutput(toolName, text); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${toolName} returned invalid JSON: ${message}`); + } +} + +async function formatJsonResult(prefix: string, title: string, bullets: string[], payload: unknown, details: Record): Promise { + const json = JSON.stringify(payload, null, 2); + const content = [title, ...bullets, "", json].join("\n"); + const truncated = await truncateToolText(prefix, content); + return { + content: [{ type: "text", text: truncated.text }], + details: { + ...details, + payload, + truncatedToFile: truncated.tempFile, + }, + }; +} + +export default function (pi: ExtensionAPI) { + const diffSelectionSchema = { + staged: Type.Optional(Type.Boolean({ description: "Use staged changes only." })), + commit: Type.Optional(Type.String({ description: "Diff a specific commit (e.g. HEAD or abc1234)." })), + from: Type.Optional(Type.String({ description: "Start ref for a commit range." })), + to: Type.Optional(Type.String({ description: "End ref for a commit range." })), + fileExts: Type.Optional( + Type.Array(Type.String(), { + description: "Optional file extensions to filter sem diff by, for example ['.ts', '.md'].", + }), + ), + }; + + pi.registerTool({ + name: "sem_diff", + label: "sem diff", + description: + "Semantic git diff with entity-level change detection. Use for change summaries, reviews, commit analysis, or when you need functions/classes/properties instead of raw line hunks.", + promptSnippet: "Entity-level git diff with JSON output and rename-aware changed entities.", + promptGuidelines: [ + "Use sem_diff when the user asks what changed, asks for a review summary, or wants commit/range analysis at the function/class/entity level.", + "Prefer sem_diff for one-shot semantic overviews and entity counts, not as a universal replacement for raw git diff or file reads.", + "When you only need to reason about one suspicious function/class, prefer sem_context; when you need blast radius or affected tests, prefer sem_impact.", + ], + parameters: Type.Object(diffSelectionSchema), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const selection = normalizeSelection(params); + onUpdate?.({ + content: [{ type: "text", text: `Running sem diff for ${selection.label}...` }], + details: { selection }, + }); + + const args = buildSemDiffArgs(params); + const result = await runSem(pi, ctx.cwd, args, signal); + const payload = parseJson("sem diff", result.stdout); + const summary = summarizeSemDiffPayload(payload); + return formatJsonResult( + "sem-diff", + `sem diff (${selection.label})`, + [ + `- files with entity changes: ${summary.summary.fileCount}`, + `- entities changed: ${summary.summary.total} (added ${summary.summary.added}, modified ${summary.summary.modified}, deleted ${summary.summary.deleted})`, + `- command: ${formatCommand(result.invocation, args)}`, + ], + payload, + { + selection, + command: formatCommand(result.invocation, args), + commandSource: result.invocation.source, + summary, + }, + ); + }, + }); + + pi.registerTool({ + name: "sem_impact", + label: "sem impact", + description: + "Cross-file impact analysis for an entity. Use to estimate blast radius, dependents, dependencies, or affected tests before refactors and reviews.", + promptSnippet: "Entity impact analysis with dependents, dependencies, and test-focused modes.", + promptGuidelines: [ + "Use sem_impact when the user asks what might break, what depends on a function/class, or what tests are affected by a change.", + "Prefer sem_impact during review before speculating about blast radius; use it to prove or disprove impact on other files, entrypoints, or tests.", + ], + parameters: Type.Object({ + entity: Type.String({ description: "Entity name to analyze, such as authenticateUser." }), + file: Type.Optional(Type.String({ description: "Optional file path to disambiguate entities with the same name." })), + scope: Type.Optional( + StringEnum(["all", "deps", "dependents", "tests"] as const, { + description: "Which part of the impact graph to return. Defaults to all.", + }), + ), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + onUpdate?.({ + content: [{ type: "text", text: `Running sem impact for ${params.entity}...` }], + details: { entity: params.entity, scope: params.scope ?? "all" }, + }); + const args = buildImpactArgs(params); + const result = await runSem(pi, ctx.cwd, args, signal); + const payload = parseJson("sem impact", result.stdout); + return formatJsonResult( + "sem-impact", + `sem impact (${params.entity})`, + [ + `- scope: ${params.scope ?? "all"}`, + `- file: ${params.file ?? "auto"}`, + `- command: ${formatCommand(result.invocation, args)}`, + ], + payload, + { + entity: params.entity, + file: params.file, + scope: params.scope ?? "all", + command: formatCommand(result.invocation, args), + commandSource: result.invocation.source, + }, + ); + }, + }); + + pi.registerTool({ + name: "sem_context", + label: "sem context", + description: + "Token-budgeted semantic context for an entity. Use when you need just the relevant function/class plus dependencies and dependents, instead of reading many full files.", + promptSnippet: "Token-budgeted entity context including dependencies and dependents.", + promptGuidelines: [ + "Use sem_context when focused work on a single function/class needs compact relevant context instead of loading whole files.", + "Prefer sem_context over large read operations when the task centers on one entity and you only need its dependencies and dependents.", + ], + parameters: Type.Object({ + entity: Type.String({ description: "Entity name to retrieve context for." }), + file: Type.Optional(Type.String({ description: "Optional file path to disambiguate the entity." })), + budget: Type.Optional(Type.Number({ description: "Approximate token budget. Defaults to sem's default (8000).", minimum: 1 })), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + onUpdate?.({ + content: [{ type: "text", text: `Running sem context for ${params.entity}...` }], + details: { entity: params.entity, budget: params.budget ?? 8000 }, + }); + const args = buildContextArgs(params); + const result = await runSem(pi, ctx.cwd, args, signal); + const header = [ + `sem context (${params.entity})`, + `- file: ${params.file ?? "auto"}`, + `- budget: ${params.budget ?? 8000}`, + `- command: ${formatCommand(result.invocation, args)}`, + "", + result.stdout.trim(), + ].join("\n"); + const truncated = await truncateToolText("sem-context", header); + return { + content: [{ type: "text", text: truncated.text }], + details: { + entity: params.entity, + file: params.file, + budget: params.budget ?? 8000, + command: formatCommand(result.invocation, args), + commandSource: result.invocation.source, + truncatedToFile: truncated.tempFile, + }, + }; + }, + }); + + pi.registerTool({ + name: "sem_log", + label: "sem log", + description: + "Entity-level history for a function/class/method. Use for debugging regressions, tracing how logic evolved, or understanding who changed a specific entity over time.", + promptSnippet: "Entity-level git history for a single function, class, or method.", + promptGuidelines: [ + "Use sem_log when the user asks how an entity evolved or which commits touched a particular function/class.", + ], + parameters: Type.Object({ + entity: Type.String({ description: "Entity name to trace through git history." }), + file: Type.Optional(Type.String({ description: "Optional file path to disambiguate entities with the same name." })), + limit: Type.Optional(Type.Number({ description: "Maximum commits to scan. Defaults to sem's default.", minimum: 1 })), + verbose: Type.Optional(Type.Boolean({ description: "Show verbose content diffs between entity versions." })), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + onUpdate?.({ + content: [{ type: "text", text: `Running sem log for ${params.entity}...` }], + details: { entity: params.entity, limit: params.limit }, + }); + const args = buildLogArgs(params); + const result = await runSem(pi, ctx.cwd, args, signal); + const payload = parseJson("sem log", result.stdout); + return formatJsonResult( + "sem-log", + `sem log (${params.entity})`, + [ + `- file: ${params.file ?? "auto"}`, + `- limit: ${params.limit ?? "default"}`, + `- verbose: ${params.verbose ? "yes" : "no"}`, + `- command: ${formatCommand(result.invocation, args)}`, + ], + payload, + { + entity: params.entity, + file: params.file, + limit: params.limit, + verbose: params.verbose ?? false, + command: formatCommand(result.invocation, args), + commandSource: result.invocation.source, + }, + ); + }, + }); + + pi.registerTool({ + name: "sem_entities", + label: "sem entities", + description: + "List entities in a file with semantic types and ranges. Use to inventory a file before editing or to understand its structure quickly.", + promptSnippet: "Entity inventory for a file: functions, classes, methods, properties, sections, and ranges.", + promptGuidelines: [ + "Use sem_entities when you need a structural inventory of a file before drilling into specific entities.", + ], + parameters: Type.Object({ + file: Type.String({ description: "File path to inspect." }), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + onUpdate?.({ + content: [{ type: "text", text: `Running sem entities for ${params.file}...` }], + details: { file: params.file }, + }); + const args = buildEntitiesArgs(params); + const result = await runSem(pi, ctx.cwd, args, signal); + const payload = parseJson("sem entities", result.stdout); + return formatJsonResult( + "sem-entities", + `sem entities (${params.file})`, + [`- command: ${formatCommand(result.invocation, args)}`], + payload, + { + file: params.file, + command: formatCommand(result.invocation, args), + commandSource: result.invocation.source, + }, + ); + }, + }); + + pi.registerTool({ + name: "sem_blame", + label: "sem blame", + description: + "Entity-level blame for a file. Use to see who last touched each function/class/method instead of only line-based blame.", + promptSnippet: "Entity-level blame for functions, classes, methods, and similar semantic units in a file.", + promptGuidelines: [ + "Use sem_blame when the user asks who last changed a function/class or which commit most recently touched entities in a file.", + ], + parameters: Type.Object({ + file: Type.String({ description: "File path to analyze." }), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + onUpdate?.({ + content: [{ type: "text", text: `Running sem blame for ${params.file}...` }], + details: { file: params.file }, + }); + const args = buildBlameArgs(params); + const result = await runSem(pi, ctx.cwd, args, signal); + const payload = parseJson("sem blame", result.stdout); + return formatJsonResult( + "sem-blame", + `sem blame (${params.file})`, + [`- command: ${formatCommand(result.invocation, args)}`], + payload, + { + file: params.file, + command: formatCommand(result.invocation, args), + commandSource: result.invocation.source, + }, + ); + }, + }); + + pi.registerTool({ + name: "sem_eval", + label: "sem eval", + description: + "Evaluate semantic diff coverage and prompt-size impact versus raw git diff for the current repo, a commit, or a range. Use when you want to benchmark how much sem helps in practice.", + promptSnippet: "Compare sem diff coverage and token footprint against raw git diff for the same selection.", + promptGuidelines: [ + "Use sem_eval when the user asks whether sem helps, wants a benchmark, or asks for measurable impact compared with raw git diff.", + ], + parameters: Type.Object({ + ...diffSelectionSchema, + includeImpact: Type.Optional(Type.Boolean({ description: "Also sample sem impact on a few changed entities." })), + impactLimit: Type.Optional(Type.Number({ description: "How many changed entities to sample for impact analysis. Defaults to 3.", minimum: 1 })), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const selection = normalizeSelection(params); + const includeImpact = params.includeImpact !== false; + const impactLimit = Math.max(1, Math.floor(params.impactLimit ?? 3)); + + onUpdate?.({ + content: [{ type: "text", text: `Evaluating sem vs git diff for ${selection.label}...` }], + details: { selection, includeImpact, impactLimit }, + }); + + const semArgs = buildSemDiffArgs(params); + const gitDiffArgs = buildGitDiffArgs(params); + const gitNameArgs = buildGitNameOnlyArgs(params); + + const [semResult, gitDiffResult, gitNamesResult] = await Promise.all([ + runSem(pi, ctx.cwd, semArgs, signal), + runGit(pi, ctx.cwd, gitDiffArgs, signal), + runGit(pi, ctx.cwd, gitNameArgs, signal), + ]); + + const semPayload = parseJson("sem diff", semResult.stdout); + const semSummary = summarizeSemDiffPayload(semPayload); + const gitFiles = gitNamesResult.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const impactTargets = includeImpact ? pickImpactTargets(semPayload, impactLimit) : []; + const impactResults: Array<{ entity: string; file: string; result: unknown }> = []; + for (const target of impactTargets) { + const impactArgs = buildImpactArgs({ entity: target.entity, file: target.file, scope: "tests" }); + try { + const impactResult = await runSem(pi, ctx.cwd, impactArgs, signal); + impactResults.push({ + entity: target.entity, + file: target.file, + result: parseJson("sem impact", impactResult.stdout), + }); + } catch (error) { + impactResults.push({ + entity: target.entity, + file: target.file, + result: { error: error instanceof Error ? error.message : String(error) }, + }); + } + } + + const report = buildEvaluationReport({ + selection, + gitDiffText: gitDiffResult.stdout, + gitFiles, + semDiffText: semResult.stdout, + semDiffJson: semPayload, + impactResults, + }); + const coverage = compareCoverage(gitFiles, semSummary.changedFiles); + const reportText = formatEvaluationReport(report); + const truncated = await truncateToolText("sem-eval", reportText); + return { + content: [{ type: "text", text: truncated.text }], + details: { + selection, + includeImpact, + impactLimit, + report, + coverage, + semSummary, + commands: { + sem: formatCommand(semResult.invocation, semArgs), + gitDiff: ["git", ...gitDiffArgs].join(" "), + gitNames: ["git", ...gitNameArgs].join(" "), + }, + truncatedToFile: truncated.tempFile, + }, + }; + }, + }); +} diff --git a/extensions/pi-sem/package.json b/extensions/pi-sem/package.json new file mode 100644 index 0000000..4a292df --- /dev/null +++ b/extensions/pi-sem/package.json @@ -0,0 +1,12 @@ +{ + "name": "pi-sem", + "private": true, + "optionalDependencies": { + "@ataraxy-labs/sem": "^0.3.21" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/pi-telescope/index.ts b/extensions/pi-telescope/index.ts index 0a1da2e..224f857 100644 --- a/extensions/pi-telescope/index.ts +++ b/extensions/pi-telescope/index.ts @@ -15,7 +15,7 @@ * - Footer with keybinding hints * * Keybindings: - * Ctrl+X → open files finder + * Ctrl+Space → open files finder * * Commands: * /telescope [name] → open specific provider @@ -26,10 +26,14 @@ */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { CustomEditor } from "@mariozechner/pi-coding-agent"; +import type { AutocompleteItem, AutocompleteProvider } from "@mariozechner/pi-tui"; import type { TelescopeProvider } from "./types.js"; import { openTelescope } from "./telescope.js"; +import { filterAndScore } from "./scoring.js"; +import { getFrecencyMap } from "./frecency.js"; -import { createFilesProvider } from "./providers/files.js"; +import { createFilesProvider, listFiles } from "./providers/files.js"; import { createGitBranchesProvider } from "./providers/git-branches.js"; import { createGitLogProvider } from "./providers/git-log.js"; @@ -86,8 +90,171 @@ async function runTelescope( }); } +// --------------------------------------------------------------------------- +// @ mention autocomplete replacement +// --------------------------------------------------------------------------- + +const MENTION_MAX_RESULTS = 20; + +/** Cache of file listings per cwd */ +let cachedFiles: string[] = []; +let cachedCwd: string | null = null; + +function ensureFileCache(cwd: string): string[] { + if (cachedCwd !== cwd) { + cachedFiles = listFiles(cwd); + cachedCwd = cwd; + } + return cachedFiles; +} + +/** Invalidate file cache (called on cwd change) */ +function invalidateFileCache(): void { + cachedFiles = []; + cachedCwd = null; +} + +/** Extract the @-prefix from text before cursor, or null if not in an @ context */ +function extractAtPrefix(textBeforeCursor: string): string | null { + const match = textBeforeCursor.match(/(?:^|[ \t])(@(?:"[^"]*|[^\s]*))$/); + return match?.[1] ?? null; +} + +/** Parse the raw query and whether it's a quoted @"..." prefix */ +function parseAtPrefix(prefix: string): { raw: string; quoted: boolean } { + if (prefix.startsWith('@"')) { + return { raw: prefix.slice(2), quoted: true }; + } + return { raw: prefix.slice(1), quoted: false }; +} + +/** Build the completion value with proper quoting for paths with spaces */ +function buildAtValue(path: string, quotedPrefix: boolean): string { + if (quotedPrefix || path.includes(" ")) { + return `@"${path}"`; + } + return `@${path}`; +} + +/** + * Autocomplete provider that intercepts @ mentions and uses telescope's + * fuzzy scoring engine to find files, delegating everything else to the + * built-in provider. + */ +class TelescopeAtMentionProvider implements AutocompleteProvider { + constructor( + private base: AutocompleteProvider, + private cwd: string, + ) {} + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> { + const currentLine = lines[cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, cursorCol); + const atPrefix = extractAtPrefix(textBeforeCursor); + + if (!atPrefix) { + return this.base.getSuggestions(lines, cursorLine, cursorCol, options); + } + + const { raw, quoted } = parseAtPrefix(atPrefix); + + try { + const files = ensureFileCache(this.cwd); + const frecency = getFrecencyMap("files"); + const scored = filterAndScore( + files, + raw, + (f) => f, + MENTION_MAX_RESULTS, + frecency, + ); + + if (scored.length === 0) { + return this.base.getSuggestions(lines, cursorLine, cursorCol, options); + } + + const items: AutocompleteItem[] = scored.map((s) => { + const path = s.item; + const fileName = path.includes("/") + ? path.slice(path.lastIndexOf("/") + 1) + : path; + return { + value: buildAtValue(path, quoted), + label: fileName, + description: path, + }; + }); + + return { items, prefix: atPrefix }; + } catch { + // Fall back to built-in on any error + return this.base.getSuggestions(lines, cursorLine, cursorCol, options); + } + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ) { + return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + } +} + +/** + * Custom editor that wraps the autocomplete provider to inject + * telescope-powered @ mention completions. + */ +class TelescopeEditor extends CustomEditor { + constructor( + tui: any, + theme: any, + keybindings: any, + private cwd: string, + ) { + super(tui, theme, keybindings); + } + + override setAutocompleteProvider(provider: AutocompleteProvider): void { + super.setAutocompleteProvider( + new TelescopeAtMentionProvider(provider, this.cwd), + ); + } +} + +// --------------------------------------------------------------------------- +// Extension +// --------------------------------------------------------------------------- + export default function (pi: ExtensionAPI) { - pi.registerShortcut("ctrl+x", { + // Register custom editor with telescope @ mentions + pi.on("session_start", async (_event, ctx) => { + try { + const cwd = ctx.cwd; + invalidateFileCache(); + ensureFileCache(cwd); + ctx.ui.setEditorComponent((tui, theme, keybindings) => + new TelescopeEditor(tui, theme, keybindings, cwd), + ); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + ctx.ui.notify(`Telescope @ init failed: ${msg}`, "error"); + } + }); + + pi.on("session_shutdown", async (_event, ctx) => { + ctx.ui.setEditorComponent(undefined); + invalidateFileCache(); + }); + + pi.registerShortcut("ctrl+space", { description: "Open Telescope fuzzy finder (files)", handler: (ctx) => runTelescope(pi, ctx, "files"), }); diff --git a/extensions/pi-telescope/preview.ts b/extensions/pi-telescope/preview.ts index 4c0d612..ac59170 100644 --- a/extensions/pi-telescope/preview.ts +++ b/extensions/pi-telescope/preview.ts @@ -61,7 +61,7 @@ function formatPreviewLines( const gutter = isHighlighted ? theme.fg("accent", `${lineNum}│`) : theme.fg("dim", `${lineNum}│`); - return `${gutter}${line}`; + return `${gutter}${expandTabs(line)}`; }); } @@ -74,6 +74,11 @@ export function plainPreview( const gutterWidth = String(lines.length).length; return lines.map((line, i) => { const lineNum = String(i + 1).padStart(gutterWidth); - return `${theme.fg("dim", `${lineNum}│`)}${line}`; + return `${theme.fg("dim", `${lineNum}│`)}${expandTabs(line)}`; }); } + +/** Replace tab characters with spaces to match pi-tui's tab width of 3. */ +function expandTabs(line: string): string { + return line.includes("\t") ? line.replace(/\t/g, " ") : line; +} diff --git a/extensions/pi-telescope/providers/files.ts b/extensions/pi-telescope/providers/files.ts index 661a84b..5191f62 100644 --- a/extensions/pi-telescope/providers/files.ts +++ b/extensions/pi-telescope/providers/files.ts @@ -22,7 +22,7 @@ function hasBinary(name: string): boolean { const useFd = hasBinary("fd"); -function listFiles(cwd: string): string[] { +export function listFiles(cwd: string): string[] { try { const cmd = useFd ? "fd --type f --hidden --follow --exclude .git --exclude node_modules --exclude .venv --exclude dist --exclude build --max-results 10000" @@ -70,13 +70,11 @@ export function createFilesProvider(cwd: string): TelescopeProvider { }, async onSelect(item, ctx) { - const fullPath = resolve(cwd, item); - ctx.ui.pasteToEditor(fullPath); + ctx.ui.pasteToEditor(`@${item}`); }, async onMultiSelect(items, ctx) { - const paths = items.map((i) => resolve(cwd, i)); - ctx.ui.pasteToEditor(paths.join(" ")); + ctx.ui.pasteToEditor(items.map((i) => `@${i}`).join(" ")); }, getPreview(item) { @@ -94,8 +92,7 @@ export function createFilesProvider(cwd: string): TelescopeProvider { async onAction(actionKey, items, ctx) { if (actionKey === "c") { - const paths = items.map((i) => resolve(cwd, i)); - copyToClipboard(paths.join("\n")); + copyToClipboard(items.join("\n")); } else if (actionKey === "i") { const { readFileSync } = await import("node:fs"); for (const item of items) { diff --git a/extensions/pi-telescope/telescope.ts b/extensions/pi-telescope/telescope.ts index 90b7b7a..1139898 100644 --- a/extensions/pi-telescope/telescope.ts +++ b/extensions/pi-telescope/telescope.ts @@ -670,37 +670,25 @@ export async function openTelescope( // ── Top border ── lines.push(bdr(`╭${hLine("─", innerWidth)}╮`)); - // ── Header ── - let headerContent: string; - if (state.mode === "provider-picker") { - headerContent = th.fg("accent", th.bold("🔀 Switch Provider")); - } else if (state.mode === "help") { - headerContent = th.fg("accent", th.bold("❓ Keybindings")); - } else if (state.mode === "action-picker") { - headerContent = `${th.fg("accent", th.bold("⚡ Actions"))} ${th.fg("dim", currentProvider.name)}`; - } else { - const title = `${currentProvider.icon} ${currentProvider.name}`; - const count = state.loading - ? th.fg("dim", "loading…") - : th.fg("dim", `${state.filtered.length}/${state.allItems.length}`); - const multi = - state.selectedKeys.size > 0 - ? th.fg("success", ` [${state.selectedKeys.size} sel]`) - : ""; - const flashMsg = state.flashMessage - ? " " + th.fg("warning", state.flashMessage) - : ""; - headerContent = `${th.fg("accent", th.bold(title))} ${count}${multi}${flashMsg}`; - } + // ── Input row ── + const query = + state.mode !== "search" ? state.modeQuery : state.query; + const cursorPos = + state.mode !== "search" + ? state.modeCursorPos + : state.cursorPos; + const promptChar = th.fg("accent", "> "); + const beforeCursor = query.slice(0, cursorPos); + const cursorChar = query[cursorPos] ?? " "; + const afterCursor = query.slice(cursorPos + 1); + const inputText = `${promptChar}${beforeCursor}\x1b[7m${cursorChar}\x1b[27m${afterCursor}`; + const inputContent = + " " + truncateToWidth(inputText, innerWidth - 2) + " "; lines.push( - bdr("│") + - " " + - padRight(headerContent, innerWidth - 2) + - " " + - bdr("│"), + bdr("│") + padRight(inputContent, innerWidth) + bdr("│"), ); - // ── Separator (header → list) ── + // ── Separator (input → list) ── if (hasPreview) { lines.push( bdr( @@ -766,7 +754,7 @@ export async function openTelescope( const previewIdx = state.previewScrollOffset + row; let rightCell = ""; if (previewIdx < state.previewLines.length) { - rightCell = state.previewLines[previewIdx] ?? ""; + rightCell = (state.previewLines[previewIdx] ?? "").replace(/\t/g, " "); } rightCell = " " + truncateToWidth(rightCell, previewWidth - 2) + " "; @@ -789,7 +777,7 @@ export async function openTelescope( } } - // ── Separator (list → input) ── + // ── Separator (list → hints) ── if (hasPreview) { lines.push( bdr( @@ -800,27 +788,6 @@ export async function openTelescope( lines.push(bdr(`├${hLine("─", innerWidth)}┤`)); } - // ── Input row ── - const query = - state.mode !== "search" ? state.modeQuery : state.query; - const cursorPos = - state.mode !== "search" - ? state.modeCursorPos - : state.cursorPos; - const promptChar = th.fg("accent", "> "); - const beforeCursor = query.slice(0, cursorPos); - const cursorChar = query[cursorPos] ?? " "; - const afterCursor = query.slice(cursorPos + 1); - const inputText = `${promptChar}${beforeCursor}\x1b[7m${cursorChar}\x1b[27m${afterCursor}`; - const inputContent = - " " + truncateToWidth(inputText, innerWidth - 2) + " "; - lines.push( - bdr("│") + padRight(inputContent, innerWidth) + bdr("│"), - ); - - // ── Separator (input → hints) ── - lines.push(bdr(`├${hLine("─", innerWidth)}┤`)); - // ── Hints row ── const hintsContent = buildHints(th, innerWidth - 2, state.mode); lines.push(bdr("│") + " " + padRight(hintsContent, innerWidth - 2) + " " + bdr("│")); @@ -848,8 +815,8 @@ export async function openTelescope( overlay: true, overlayOptions: { anchor: "top-center" as const, - offsetY: 3, - width: "90%", + offsetY: 1, + width: "95%", minWidth: 80, maxHeight: "85%", }, diff --git a/extensions/piff/alt-catppuccin-mocha.json b/extensions/piff/alt-catppuccin-mocha.json deleted file mode 100644 index 263bb9a..0000000 --- a/extensions/piff/alt-catppuccin-mocha.json +++ /dev/null @@ -1,956 +0,0 @@ -{ - "name": "Catppuccin Monokai Mocha", - "type": "dark", - "semanticHighlighting": true, - "semanticTokenColors": { - "enumMember": { - "foreground": "#cba6f7" - }, - "namespace": { - "foreground": "#cdd6f4" - }, - "concept": { - "foreground": "#f5e0dc" - } - }, - "tokenColors": [ - { - "name": "Comment", - "scope": [ - "comment", - "string.comment" - ], - "settings": { - "fontStyle": "italic", - "foreground": "#6c7086" - } - }, - { - "name": "String", - "scope": [ - "string", - "string.template" - ], - "settings": { - "foreground": "#f9e2af" - } - }, - { - "name": "Number", - "scope": "constant.numeric", - "settings": { - "foreground": "#cba6f7" - } - }, - { - "name": "Embedded String Begin/End", - "scope": [ - "string.embedded.begin", - "string.embedded.end", - "punctuation.definition.template-expression", - "punctuation.section.embedded" - ], - "settings": { - "foreground": "#cba6f7" - } - }, - { - "name": "Default Embedded Color", - "scope": [ - "punctuation.section.embedded.begin.js", - "punctuation.section.embedded.end.js", - "punctuation.section.embedded.begin.erb", - "punctuation.section.embedded.end.erb", - "source.elixir.embedded", - "punctuation.separator", - "punctuation.accessor", - "meta.brace" - ], - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "Built-in constant", - "scope": "constant.language", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "User-defined constant", - "scope": [ - "constant.character", - "constant.other" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Language Variable", - "scope": "variable.language", - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "Keyword", - "scope": [ - "keyword", - "keyword.operator.logical", - "keyword.operator.constructor" - ], - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "Keyword Operator", - "scope": "keyword.operator", - "settings": { - "fontStyle": "bold", - "foreground": "#f38ba8" - } - }, - { - "name": "Storage", - "scope": "storage", - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "Types", - "scope": "storage.type", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Class", - "scope": [ - "entity.name.class", - "entity.name.module", - "entity.name.type", - "storage.identifier", - "support.class" - ], - "settings": { - "foreground": "#89b4fa" - } - }, - { - "name": "Variable Object", - "scope": [ - "variable.other.object", - "variable.other.constant", - "variable.other.global", - "variable.other.readwrite.class", - "variable.other.readwrite.instance", - "variable.other.readwrite.batchfile", - "variable.readwrite", - "variable.readwrite.other.block" - ], - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "Other variable", - "scope": [ - "variable.other", - "variable.other.property", - "variable.other.block" - ], - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "Inherited Class", - "scope": "entity.other.inherited-class", - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Package Import", - "scope": [ - "storage.modifier.import", - "storage.modifier.package" - ], - "settings": { - "foreground": "#89b4fa" - } - }, - { - "name": "Function name", - "scope": [ - "entity.name.function", - "support.function" - ], - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Function argument", - "scope": [ - "variable.parameter", - "entity.name.variable.parameter", - "parameter.variable" - ], - "settings": { - "fontStyle": "italic", - "foreground": "#fab387" - } - }, - { - "name": "Function call", - "scope": "entity.name.function-call", - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "Builtin Functions", - "scope": [ - "function.support.builtin", - "function.support.core" - ], - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Tag Name", - "scope": [ - "entity.name.tag", - "entity.name.tag.class.js" - ], - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "Tag Class and ID", - "scope": [ - "entity.name.tag.class", - "entity.name.tag.id" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Tag Attribute", - "scope": "entity.other.attribute-name", - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Library constant", - "scope": "support.constant", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Library class/type", - "scope": [ - "support.type", - "support.variable" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Json Property", - "scope": "support.dictionary.json", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "StyleSheet Property name", - "scope": [ - "support.type.property-name.css", - "support.type.property-name.scss", - "support.type.property-name.less", - "support.type.property-name.sass" - ], - "settings": { - "foreground": "#cdd6f4" - } - }, - { - "name": "CSS: Pseudo Attribute Names", - "scope": [ - "entity.other.attribute-name.pseudo-class.css", - "entity.other.attribute-name.pseudo-class.scss", - "entity.other.attribute-name.pseudo-class.less", - "entity.other.attribute-name.pseudo-class.sass", - "entity.other.attribute-name.pseudo-element.css", - "entity.other.attribute-name.pseudo-element.scss", - "entity.other.attribute-name.pseudo-element.less", - "entity.other.attribute-name.pseudo-element.sass" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "StyleSheet Property value", - "scope": [ - "support.constant.css", - "support.constant.scss", - "support.constant.less", - "support.constant.sass" - ], - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "StyleSheet Variable", - "scope": [ - "variable.css", - "variable.scss", - "variable.less", - "variable.sass" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "StyleSheet Variable String", - "scope": [ - "variable.css.string", - "variable.scss.string", - "variable.less.string", - "variable.sass.string" - ], - "settings": { - "foreground": "#f9e2af" - } - }, - { - "name": "StyleSheet Unit", - "scope": [ - "unit.css", - "unit.scss", - "unit.less", - "unit.sass" - ], - "settings": { - "foreground": "#cba6f7" - } - }, - { - "name": "StyleSheet Function", - "scope": [ - "function.css", - "function.scss", - "function.less", - "function.sass" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Library variable", - "scope": "support.other.variable", - "settings": { - "fontStyle": "" - } - }, - { - "name": "Invalid", - "scope": "invalid", - "settings": { - "background": "#cba6f7", - "foreground": "#f5e0dc" - } - }, - { - "name": "Invalid deprecated", - "scope": "invalid.deprecated", - "settings": { - "background": "#89dceb", - "foreground": "#f5e0dc" - } - }, - { - "name": "JSON String", - "scope": "support.type.property-name.json", - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Link", - "scope": "string.detected-link", - "settings": { - "foreground": "#89b4fa" - } - }, - { - "name": "diff.header", - "scope": [ - "meta.diff", - "meta.diff.header" - ], - "settings": { - "foreground": "#75715E" - } - }, - { - "name": "diff.deleted", - "scope": "markup.deleted", - "settings": { - "foreground": "#f38ba8" - } - }, - { - "name": "diff.inserted", - "scope": "markup.inserted", - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "diff.changed", - "scope": "markup.changed", - "settings": { - "foreground": "#f9e2af" - } - }, - { - "scope": "constant.numeric.line-number.find-in-files - match", - "settings": { - "foreground": "#56b6c2A0" - } - }, - { - "scope": "entity.name.filename.find-in-files", - "settings": { - "foreground": "#f9e2af" - } - }, - { - "name": "Markup: Emphasis", - "scope": "markup.italic, markup.italic.markdown", - "settings": { - "fontStyle": "italic" - } - }, - { - "name": "Markup: Punctuation", - "scope": [ - "punctuation.definition.italic.markdown", - "punctuation.definition.bold.markdown", - "punctuation.definition.heading.markdown" - ], - "settings": { - "foreground": "#6c7086" - } - }, - { - "name": "Markup: Emphasis Punctuation", - "scope": "punctuation.definition.italic.markdown", - "settings": { - "fontStyle": "italic" - } - }, - { - "name": "Markdown: Link", - "scope": "markup.underline.link.markdown", - "settings": { - "foreground": "#89b4fa" - } - }, - { - "name": "Markup: Bold", - "scope": "markup.bold.markdown", - "settings": { - "fontStyle": "bold" - } - }, - { - "name": "Markup: Heading", - "scope": "markup.heading.markdown", - "settings": { - "fontStyle": "bold", - "foreground": "#f38ba8" - } - }, - { - "name": "Markup: Quote", - "scope": "markup.quote.markdown", - "settings": { - "foreground": "#a6e3a1" - } - }, - { - "name": "Markup: Separator", - "scope": "meta.separator.markdown", - "settings": { - "foreground": "#cba6f7", - "fontStyle": "bold" - } - }, - { - "name": "Markup: Raw", - "scope": [ - "markup.raw.inline.markdown", - "markup.raw.block.markdown" - ], - "settings": { - "foreground": "#89dceb" - } - }, - { - "name": "Markup: List Punctuation", - "scope": "punctuation.definition.list_item.markdown", - "settings": { - "fontStyle": "bold", - "foreground": "#ffffff" - } - } - ], - "colors": { - "focusBorder": "#00000000", - "foreground": "#cdd6f4", - "disabledForeground": "#a6adc8", - "widget.shadow": "#17131F7f", - "selection.background": "#585b70", - "descriptionForeground": "#cdd6f4", - "errorForeground": "#f38ba8", - "icon.foreground": "#cba6f7", - "sash.hoverBorder": "#cba6f7", - "window.activeBorder": "#00000000", - "window.inactiveBorder": "#00000000", - "textBlockQuote.background": "#17131F", - "textBlockQuote.border": "#17131F", - "textCodeBlock.background": "#17131F", - "textLink.activeForeground": "#89dceb", - "textLink.foreground": "#89b4fa", - "textPreformat.foreground": "#cdd6f4", - "textSeparator.foreground": "#cba6f7", - "activityBar.background": "#17131F", - "activityBar.foreground": "#cba6f7", - "activityBar.dropBar": "#585b7099", - "activityBar.inactiveForeground": "#6c7086", - "activityBar.border": "#00000000", - "activityBarBadge.background": "#cba6f7", - "activityBarBadge.foreground": "#17131F", - "activityBar.activeBorder": "#00000000", - "activityBar.activeBackground": "#00000000", - "activityBar.activeFocusBorder": "#00000000", - "badge.background": "#45475a", - "badge.foreground": "#cdd6f4", - "breadcrumb.activeSelectionForeground": "#cba6f7", - "breadcrumb.background": "#17131F", - "breadcrumb.focusForeground": "#cba6f7", - "breadcrumb.foreground": "#cdd6f4cc", - "breadcrumbPicker.background": "#17131F", - "button.background": "#cba6f7", - "button.foreground": "#17131F", - "button.border": "#00000000", - "button.separator": "#00000000", - "button.hoverBackground": "#dfbaff", - "button.secondaryForeground": "#cdd6f4", - "button.secondaryBackground": "#585b70", - "button.secondaryHoverBackground": "#6c6f84", - "checkbox.background": "#45475a", - "checkbox.border": "#00000000", - "checkbox.foreground": "#cba6f7", - "dropdown.background": "#17131F", - "dropdown.listBackground": "#585b70", - "dropdown.border": "#cba6f7", - "dropdown.foreground": "#cdd6f4", - "debugToolBar.background": "#17131F", - "debugToolBar.border": "#00000000", - "debugExceptionWidget.background": "#17131F", - "debugExceptionWidget.border": "#cba6f7", - "debugTokenExpression.number": "#fab387", - "debugTokenExpression.boolean": "#cba6f7", - "debugTokenExpression.string": "#a6e3a1", - "debugTokenExpression.error": "#f38ba8", - "debugIcon.breakpointForeground": "#f38ba8", - "debugIcon.breakpointDisabledForeground": "#f38ba899", - "debugIcon.breakpointUnverifiedForeground": "#17131F", - "debugIcon.breakpointCurrentStackframeForeground": "#585b70", - "debugIcon.breakpointStackframeForeground": "#585b70", - "debugIcon.startForeground": "#a6e3a1", - "debugIcon.pauseForeground": "#89b4fa", - "debugIcon.stopForeground": "#f38ba8", - "debugIcon.disconnectForeground": "#585b70", - "debugIcon.restartForeground": "#94e2d5", - "debugIcon.stepOverForeground": "#cba6f7", - "debugIcon.stepIntoForeground": "#cdd6f4", - "debugIcon.stepOutForeground": "#cdd6f4", - "debugIcon.continueForeground": "#a6e3a1", - "debugIcon.stepBackForeground": "#585b70", - "debugConsole.infoForeground": "#89b4fa", - "debugConsole.warningForeground": "#fab387", - "debugConsole.errorForeground": "#f38ba8", - "debugConsole.sourceForeground": "#f5e0dc", - "debugConsoleInputIcon.foreground": "#cdd6f4", - "diffEditor.border": "#585b70", - "diffEditor.insertedTextBackground": "#a6e3a119", - "diffEditor.removedTextBackground": "#f38ba819", - "diffEditor.insertedLineBackground": "#a6e3a126", - "diffEditor.removedLineBackground": "#f38ba826", - "diffEditor.diagonalFill": "#585b7099", - "diffEditorOverview.insertedForeground": "#a6e3a1cc", - "diffEditorOverview.removedForeground": "#f38ba8cc", - "editor.background": "#17131F", - "editor.findMatchBackground": "#585b70", - "editor.findMatchBorder": "#cba6f766", - "editor.findMatchHighlightBackground": "#fab38759", - "editor.findMatchHighlightBorder": "#00000000", - "editor.findRangeHighlightBackground": "#585b704c", - "editor.findRangeHighlightBorder": "#00000000", - "editor.foldBackground": "#89dceb3f", - "editor.foreground": "#cdd6f4", - "editor.hoverHighlightBackground": "#89dceb3f", - "editor.inactiveSelectionBackground": "#00000000", - "editor.lineHighlightBackground": "#cdd6f411", - "editor.lineHighlightBorder": "#17131F", - "editor.rangeHighlightBackground": "#89dceb3f", - "editor.rangeHighlightBorder": "#00000000", - "editor.selectionBackground": "#585b70", - "editor.selectionHighlightBackground": "#9399b266", - "editor.selectionHighlightBorder": "#89dceb33", - "editor.wordHighlightBackground": "#585b70b2", - "editor.wordHighlightStrongBackground": "#585b707f", - "editorBracketMatch.background": "#9399b219", - "editorBracketMatch.border": "#9399b2", - "editorCodeLens.foreground": "#7f849c", - "editorCursor.background": "#17131F", - "editorCursor.foreground": "#f5e0dc", - "editorError.background": "#00000000", - "editorError.border": "#00000000", - "editorError.foreground": "#f38ba8", - "editorGroup.border": "#585b70", - "editorGroup.dropBackground": "#585b7099", - "editorGroup.emptyBackground": "#17131F", - "editorGroupHeader.tabsBackground": "#17131F", - "editorGutter.addedBackground": "#a6e3a1", - "editorGutter.background": "#17131F", - "editorGutter.commentRangeForeground": "#9399b2", - "editorGutter.deletedBackground": "#f38ba8", - "editorGutter.foldingControlForeground": "#9399b2", - "editorGutter.modifiedBackground": "#89dceb", - "editorHoverWidget.background": "#17131F", - "editorHoverWidget.border": "#585b70", - "editorHoverWidget.foreground": "#cdd6f4", - "editorIndentGuide.activeBackground": "#585b70", - "editorIndentGuide.background": "#45475a", - "editorInfo.background": "#00000000", - "editorInfo.border": "#00000000", - "editorInfo.foreground": "#89b4fa", - "editorInlayHint.foreground": "#585b70", - "editorInlayHint.background": "#17131Fbf", - "editorInlayHint.typeForeground": "#bac2de", - "editorInlayHint.typeBackground": "#17131Fbf", - "editorInlayHint.parameterForeground": "#a6adc8", - "editorInlayHint.parameterBackground": "#17131Fbf", - "editorLineNumber.activeForeground": "#cba6f7", - "editorLineNumber.foreground": "#7f849c", - "editorLink.activeForeground": "#cba6f7", - "editorMarkerNavigation.background": "#17131F", - "editorMarkerNavigationError.background": "#f38ba8", - "editorMarkerNavigationInfo.background": "#89b4fa", - "editorMarkerNavigationWarning.background": "#f9e2af", - "editorOverviewRuler.background": "#17131F", - "editorOverviewRuler.border": "#cdd6f411", - "editorRuler.foreground": "#585b70", - "editor.stackFrameHighlightBackground": "#f9e2af26", - "editor.focusedStackFrameHighlightBackground": "#a6e3a126", - "editorStickyScrollHover.background": "#313244", - "editorSuggestWidget.background": "#17131F", - "editorSuggestWidget.border": "#585b70", - "editorSuggestWidget.foreground": "#cdd6f4", - "editorSuggestWidget.highlightForeground": "#cba6f7", - "editorSuggestWidget.selectedBackground": "#313244", - "editorWarning.background": "#00000000", - "editorWarning.border": "#00000000", - "editorWarning.foreground": "#fab387", - "editorWhitespace.foreground": "#9399b266", - "editorWidget.background": "#17131F", - "editorWidget.foreground": "#cdd6f4", - "editorWidget.resizeBorder": "#585b70", - "editorLightBulb.foreground": "#f9e2af", - "extensionButton.prominentForeground": "#17131F", - "extensionButton.prominentBackground": "#cba6f7", - "extensionButton.prominentHoverBackground": "#dfbaff", - "extensionBadge.remoteBackground": "#89b4fa", - "extensionBadge.remoteForeground": "#17131F", - "extensionIcon.starForeground": "#f9e2af", - "extensionIcon.verifiedForeground": "#a6e3a1", - "extensionIcon.preReleaseForeground": "#f5e0dc", - "extensionIcon.sponsorForeground": "#f5c2e7", - "gitDecoration.addedResourceForeground": "#a6e3a1", - "gitDecoration.conflictingResourceForeground": "#cba6f7", - "gitDecoration.deletedResourceForeground": "#f38ba8", - "gitDecoration.ignoredResourceForeground": "#6c7086", - "gitDecoration.modifiedResourceForeground": "#f9e2af", - "gitDecoration.stageDeletedResourceForeground": "#f38ba8", - "gitDecoration.stageModifiedResourceForeground": "#f9e2af", - "gitDecoration.submoduleResourceForeground": "#89b4fa", - "gitDecoration.untrackedResourceForeground": "#a6e3a1", - "input.background": "#313244", - "input.border": "#00000000", - "input.foreground": "#cdd6f4", - "input.placeholderForeground": "#cdd6f472", - "inputOption.activeBackground": "#89b4fa26", - "inputOption.activeBorder": "#17131F33", - "inputOption.activeForeground": "#cdd6f4", - "inputValidation.errorBackground": "#f38ba8", - "inputValidation.errorBorder": "#17131F33", - "inputValidation.errorForeground": "#17131F", - "inputValidation.infoBackground": "#89b4fa", - "inputValidation.infoBorder": "#17131F33", - "inputValidation.infoForeground": "#17131F", - "inputValidation.warningBackground": "#fab387", - "inputValidation.warningBorder": "#17131F33", - "inputValidation.warningForeground": "#17131F", - "list.activeSelectionBackground": "#45475a", - "list.activeSelectionForeground": "#cdd6f4", - "list.dropBackground": "#585b7099", - "list.focusBackground": "#313244", - "list.focusForeground": "#cdd6f4", - "list.focusOutline": "#00000000", - "list.highlightForeground": "#cba6f7", - "list.hoverBackground": "#17131F", - "list.hoverForeground": "#cdd6f4", - "list.inactiveSelectionBackground": "#45475a", - "list.inactiveSelectionForeground": "#cdd6f4", - "list.warningForeground": "#f9e2af", - "listFilterWidget.background": "#45475a", - "listFilterWidget.noMatchesOutline": "#f38ba8", - "listFilterWidget.outline": "#00000000", - "tree.indentGuidesStroke": "#6c7086", - "menu.background": "#17131F", - "menu.border": "#17131F7f", - "menu.foreground": "#cdd6f4", - "menu.selectionBackground": "#585b70", - "menu.selectionBorder": "#00000000", - "menu.selectionForeground": "#cdd6f4", - "menu.separatorBackground": "#585b70", - "menubar.selectionBackground": "#45475a", - "menubar.selectionForeground": "#cdd6f4", - "merge.commonContentBackground": "#45475a", - "merge.commonHeaderBackground": "#585b70", - "merge.currentContentBackground": "#a6e3a133", - "merge.currentHeaderBackground": "#a6e3a166", - "merge.incomingContentBackground": "#89b4fa33", - "merge.incomingHeaderBackground": "#89b4fa66", - "minimap.background": "#17131F7f", - "minimap.errorHighlight": "#f38ba8", - "minimap.findMatchHighlight": "#585b70", - "minimap.selectionHighlight": "#585b70", - "minimap.warningHighlight": "#f9e2af", - "minimapGutter.addedBackground": "#a6e3a1", - "minimapGutter.deletedBackground": "#f38ba8", - "minimapGutter.modifiedBackground": "#89dceb", - "notificationCenter.border": "#cba6f7", - "notificationCenterHeader.foreground": "#cdd6f4", - "notificationCenterHeader.background": "#17131F", - "notificationToast.border": "#cba6f7", - "notifications.foreground": "#cdd6f4", - "notifications.background": "#17131F", - "notifications.border": "#cba6f7", - "notificationLink.foreground": "#89b4fa", - "notificationsErrorIcon.foreground": "#f38ba8", - "notificationsWarningIcon.foreground": "#fab387", - "notificationsInfoIcon.foreground": "#89b4fa", - "panel.background": "#17131F", - "panel.border": "#585b70", - "panelSection.border": "#585b70", - "panelSection.dropBackground": "#585b7099", - "panelTitle.activeBorder": "#cdd6f4", - "panelTitle.activeForeground": "#cdd6f4", - "panelTitle.inactiveForeground": "#a6adc8", - "peekView.border": "#cba6f7", - "peekViewEditor.background": "#17131F", - "peekViewEditor.matchHighlightBackground": "#fab3873f", - "peekViewEditor.matchHighlightBorder": "#fab387", - "peekViewEditorGutter.background": "#17131F", - "peekViewResult.background": "#17131F", - "peekViewResult.fileForeground": "#cdd6f4", - "peekViewResult.lineForeground": "#cdd6f4", - "peekViewResult.matchHighlightBackground": "#fab3873f", - "peekViewResult.selectionBackground": "#313244", - "peekViewResult.selectionForeground": "#cdd6f4", - "peekViewTitle.background": "#17131F", - "peekViewTitleDescription.foreground": "#bac2deb2", - "peekViewTitleLabel.foreground": "#cdd6f4", - "pickerGroup.border": "#cba6f7", - "pickerGroup.foreground": "#cba6f7", - "progressBar.background": "#cba6f7", - "scrollbar.shadow": "#17131F", - "scrollbarSlider.activeBackground": "#31324466", - "scrollbarSlider.background": "#585b707f", - "scrollbarSlider.hoverBackground": "#6c7086", - "settings.focusedRowBackground": "#585b7033", - "settings.headerForeground": "#cdd6f4", - "settings.modifiedItemIndicator": "#cba6f7", - "settings.dropdownBackground": "#45475a", - "settings.dropdownListBorder": "#00000000", - "settings.textInputBackground": "#45475a", - "settings.textInputBorder": "#00000000", - "settings.numberInputBackground": "#45475a", - "settings.numberInputBorder": "#00000000", - "sideBar.background": "#17131F", - "sideBar.dropBackground": "#585b7099", - "sideBar.foreground": "#cdd6f4", - "sideBar.border": "#00000000", - "sideBarSectionHeader.background": "#17131F", - "sideBarSectionHeader.foreground": "#cdd6f4", - "sideBarTitle.foreground": "#cba6f7", - "sideBarTitle.background": "#17131F", - "statusBar.background": "#17131F", - "statusBar.foreground": "#cdd6f4", - "statusBar.border": "#00000000", - "statusBar.noFolderBackground": "#17131F", - "statusBar.noFolderForeground": "#cdd6f4", - "statusBar.debuggingBackground": "#fab387", - "statusBar.debuggingForeground": "#17131F", - "statusBarItem.remoteBackground": "#89b4fa", - "statusBarItem.remoteForeground": "#17131F", - "statusBarItem.activeBackground": "#585b7066", - "statusBarItem.hoverBackground": "#585b7033", - "statusBarItem.prominentForeground": "#cba6f7", - "statusBarItem.prominentBackground": "#00000000", - "statusBarItem.prominentHoverBackground": "#585b7033", - "statusBarItem.errorForeground": "#f38ba8", - "statusBarItem.errorBackground": "#00000000", - "statusBarItem.warningForeground": "#fab387", - "statusBarItem.warningBackground": "#00000000", - "commandCenter.foreground": "#bac2de", - "commandCenter.activeForeground": "#cba6f7", - "commandCenter.background": "#17131F", - "commandCenter.activeBackground": "#585b7033", - "commandCenter.border": "#cba6f7", - "tab.activeBackground": "#17131F", - "tab.activeBorder": "#cba6f7", - "tab.activeBorderTop": "#00000000", - "tab.activeForeground": "#cba6f7", - "tab.border": "#17131F", - "tab.inactiveBackground": "#17131F", - "tab.inactiveForeground": "#6c7086", - "terminal.ansiBlack": "#6c7086", - "terminal.ansiBlue": "#89b4fa", - "terminal.ansiBrightBlack": "#7f849c", - "terminal.ansiBrightBlue": "#89b4fa", - "terminal.ansiBrightCyan": "#89dceb", - "terminal.ansiBrightGreen": "#a6e3a1", - "terminal.ansiBrightMagenta": "#f5c2e7", - "terminal.ansiBrightRed": "#f38ba8", - "terminal.ansiBrightWhite": "#cdd6f4", - "terminal.ansiBrightYellow": "#f9e2af", - "terminal.ansiCyan": "#89dceb", - "terminal.ansiGreen": "#a6e3a1", - "terminal.ansiMagenta": "#f5c2e7", - "terminal.ansiRed": "#f38ba8", - "terminal.ansiWhite": "#9399b2", - "terminal.ansiYellow": "#f9e2af", - "terminal.border": "#585b70", - "terminal.foreground": "#cdd6f4", - "terminal.dropBackground": "#585b7099", - "terminal.selectionBackground": "#585b70", - "terminalCursor.background": "#17131F", - "terminalCursor.foreground": "#f5e0dc", - "titleBar.activeBackground": "#17131F", - "titleBar.activeForeground": "#cdd6f4", - "titleBar.inactiveBackground": "#17131F", - "titleBar.inactiveForeground": "#cdd6f47f", - "titleBar.border": "#00000000", - "welcomePage.tileBackground": "#17131F", - "welcomePage.progress.background": "#17131F", - "welcomePage.progress.foreground": "#cba6f7", - "walkThrough.embeddedEditorBackground": "#17131F4c", - "symbolIcon.textForeground": "#cdd6f4", - "symbolIcon.arrayForeground": "#fab387", - "symbolIcon.booleanForeground": "#cba6f7", - "symbolIcon.classForeground": "#f9e2af", - "symbolIcon.colorForeground": "#f5c2e7", - "symbolIcon.constantForeground": "#fab387", - "symbolIcon.constructorForeground": "#b4befe", - "symbolIcon.enumeratorForeground": "#f9e2af", - "symbolIcon.enumeratorMemberForeground": "#f9e2af", - "symbolIcon.eventForeground": "#f5c2e7", - "symbolIcon.fieldForeground": "#cdd6f4", - "symbolIcon.fileForeground": "#cba6f7", - "symbolIcon.folderForeground": "#cba6f7", - "symbolIcon.functionForeground": "#89b4fa", - "symbolIcon.interfaceForeground": "#f9e2af", - "symbolIcon.keyForeground": "#94e2d5", - "symbolIcon.keywordForeground": "#cba6f7", - "symbolIcon.methodForeground": "#89b4fa", - "symbolIcon.moduleForeground": "#cdd6f4", - "symbolIcon.namespaceForeground": "#f9e2af", - "symbolIcon.nullForeground": "#eba0ac", - "symbolIcon.numberForeground": "#fab387", - "symbolIcon.objectForeground": "#f9e2af", - "symbolIcon.operatorForeground": "#94e2d5", - "symbolIcon.packageForeground": "#f2cdcd", - "symbolIcon.propertyForeground": "#eba0ac", - "symbolIcon.referenceForeground": "#f9e2af", - "symbolIcon.snippetForeground": "#f2cdcd", - "symbolIcon.stringForeground": "#a6e3a1", - "symbolIcon.structForeground": "#94e2d5", - "symbolIcon.typeParameterForeground": "#eba0ac", - "symbolIcon.unitForeground": "#cdd6f4", - "symbolIcon.variableForeground": "#cdd6f4", - "charts.foreground": "#cdd6f4", - "charts.lines": "#bac2de", - "charts.red": "#f38ba8", - "charts.blue": "#89b4fa", - "charts.yellow": "#f9e2af", - "charts.orange": "#fab387", - "charts.green": "#a6e3a1", - "charts.purple": "#cba6f7", - "errorLens.errorBackground": "#f38ba826", - "errorLens.errorMessageBackground": "#f38ba826", - "errorLens.errorBackgroundLight": "#f38ba826", - "errorLens.errorForeground": "#f38ba8", - "errorLens.errorForegroundLight": "#f38ba8", - "errorLens.warningBackground": "#fab38726", - "errorLens.warningMessageBackground": "#fab38726", - "errorLens.warningBackgroundLight": "#fab38726", - "errorLens.warningForeground": "#fab387", - "errorLens.warningForegroundLight": "#fab387", - "errorLens.infoBackground": "#89b4fa26", - "errorLens.infoMessageBackground": "#89b4fa26", - "errorLens.infoBackgroundLight": "#89b4fa26", - "errorLens.infoForeground": "#89b4fa", - "errorLens.infoForegroundLight": "#89b4fa", - "errorLens.hintBackground": "#a6e3a126", - "errorLens.hintMessageBackground": "#a6e3a126", - "errorLens.hintBackgroundLight": "#a6e3a126", - "errorLens.hintForeground": "#a6e3a1", - "errorLens.hintForegroundLight": "#a6e3a1", - "errorLens.statusBarIconErrorForeground": "#f38ba8", - "errorLens.statusBarIconWarningForeground": "#fab387", - "errorLens.statusBarErrorForeground": "#f38ba8", - "errorLens.statusBarWarningForeground": "#fab387", - "errorLens.statusBarInfoForeground": "#89b4fa", - "errorLens.statusBarHintForeground": "#a6e3a1", - "editorBracketHighlight.foreground1": "#f38ba8", - "editorBracketHighlight.foreground2": "#fab387", - "editorBracketHighlight.foreground3": "#f9e2af", - "editorBracketHighlight.foreground4": "#a6e3a1", - "editorBracketHighlight.foreground5": "#74c7ec", - "editorBracketHighlight.foreground6": "#cba6f7", - "editorBracketHighlight.unexpectedBracket.foreground": "#eba0ac" - } -} \ No newline at end of file diff --git a/extensions/piff/cli.ts b/extensions/piff/cli.ts deleted file mode 100644 index 20a3a4b..0000000 --- a/extensions/piff/cli.ts +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bun -/** - * CLI entry point for piff. - * - * Usage: - * bun extensions/piff/cli.ts → working tree changes - * bun extensions/piff/cli.ts main → diff against main - * bun extensions/piff/cli.ts HEAD~1 → last commit - * bun extensions/piff/cli.ts main..feat → compare two branches - */ - -import { isGitRepo, parseRefs, getRepoRoot } from "./git.js"; -import { startServer } from "./server.js"; - -const PORT = 5491; -const cwd = process.cwd(); - -if (!isGitRepo(cwd)) { - console.error("Error: not a git repository"); - process.exit(1); -} - -const refs = process.argv.slice(2).filter(Boolean); -const parsed = parseRefs(refs, cwd); -if ("error" in parsed) { - console.error(`Error: invalid git ref '${parsed.error}'`); - process.exit(1); -} - -const { diffArgs, description } = parsed; - -try { - await startServer({ - port: PORT, - diffArgs, - description, - cwd: getRepoRoot(cwd), - }); - console.log(`piff → http://localhost:${PORT} (${description})`); - console.log("Press Ctrl+C to stop"); -} catch (err) { - console.error("Failed to start piff:", err); - process.exit(1); -} diff --git a/extensions/piff/diff-session.ts b/extensions/piff/diff-session.ts deleted file mode 100644 index 89c9270..0000000 --- a/extensions/piff/diff-session.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { - getChangedFiles, - getOldNewContents, - bulkGetAllContents, - contentHash, - getRepoName, - getCurrentBranch, - type ChangedFile, -} from "./git.js"; -import { - SSRWorker, - stripTrailingWS, - type RenderOptions, -} from "./ssr-worker-client.js"; -import { LRUCache } from "./lru-cache.js"; - -export type { RenderOptions } from "./ssr-worker-client.js"; - -const DEFAULT_RENDER_OPTIONS: RenderOptions = { - diffStyle: "split", - expandUnchanged: true, - ignoreWhitespace: false, -}; - -const CACHE_MAX_ENTRIES = 500; - -/** Number of SSR worker subprocesses for background preloading. */ -const PRELOAD_WORKERS = 2; - -function optionsKey(opts: RenderOptions): string { - return `${opts.diffStyle}:${opts.expandUnchanged}:${opts.ignoreWhitespace}`; -} - -function applyWhitespace( - oldContent: string | null, - newContent: string | null, - ignoreWhitespace: boolean, -): { oldContent: string | null; newContent: string | null } { - if (!ignoreWhitespace) return { oldContent, newContent }; - return { - oldContent: stripTrailingWS(oldContent), - newContent: stripTrailingWS(newContent), - }; -} - -/** - * Manages diff cache and background preloading for one piff session. - * - * Architecture: - * - **userWorker**: dedicated SSR worker for user-initiated renders (never blocked) - * - **preloadWorkers[]**: pool of SSR workers for background preloading - * - * Preload uses `git cat-file --batch` to bulk-read all file contents (~30× faster - * than sequential `git show`), then distributes SSR rendering across the worker pool. - * - * Cache keys = content hash + options key, so: - * - refresh() doesn't clear cache — unchanged files keep their entries - * - switching split↔unified keeps old entries (LRU evicts coldest) - */ -export class DiffSession { - private cache = new LRUCache(CACHE_MAX_ENTRIES); - private userWorker = new SSRWorker(); - private preloadWorkers: SSRWorker[] = []; - private aborted = false; - private filesCache: ChangedFile[] | null = null; - renderOptions: RenderOptions; - - private knownHashes = new Map(); - private contentsCache = new Map(); - private filesByPath = new Map(); - - /** Cached repo metadata (avoids repeated git process spawns). */ - repoName: string; - branch: string; - - /** Hint: which file the user is currently viewing (for preload ordering). */ - currentFile: string | null = null; - - constructor( - private diffArgs: string[], - private cwd: string, - ) { - this.renderOptions = { ...DEFAULT_RENDER_OPTIONS }; - this.repoName = getRepoName(cwd); - this.branch = getCurrentBranch(cwd); - for (let i = 0; i < PRELOAD_WORKERS; i++) { - this.preloadWorkers.push(new SSRWorker()); - } - // Eagerly start all workers so they're warm when first needed - this.userWorker.ensure().catch(() => {}); - for (const w of this.preloadWorkers) w.ensure().catch(() => {}); - } - - getFiles(): ChangedFile[] { - if (!this.filesCache) { - this.filesCache = getChangedFiles(this.diffArgs, this.cwd); - this.filesByPath.clear(); - for (const f of this.filesCache) this.filesByPath.set(f.path, f); - } - return this.filesCache; - } - - getFileByPath(path: string): ChangedFile | undefined { - this.getFiles(); // ensure populated - return this.filesByPath.get(path); - } - - private _preloadStatusCache: { cached: number; total: number; preloading: boolean } | null = null; - private _preloadStatusTs = 0; - - get cachedCount(): number { - return this.preloadStatus().cached; - } - - private cacheKey(hash: string): string { - return hash + "\0" + optionsKey(this.renderOptions); - } - - /** Invalidate preloadStatus throttle when cache changes. */ - private cacheSet(key: string, html: string): void { - this.cache.set(key, html); - this._preloadStatusCache = null; - } - - // ── User-facing render (dedicated worker, never blocked) ────── - - /** Get old/new contents, using bulk-preload cache when available. */ - private getContents(file: ChangedFile): { oldContent: string | null; newContent: string | null } { - const cached = this.contentsCache.get(file.path); - if (cached) return cached; - return getOldNewContents(file, this.diffArgs, this.cwd); - } - - async getDiffHTMLForUser(file: ChangedFile): Promise { - this.currentFile = file.path; - let { oldContent, newContent } = this.getContents(file); - ({ oldContent, newContent } = applyWhitespace( - oldContent, newContent, this.renderOptions.ignoreWhitespace, - )); - const hash = contentHash(oldContent, newContent); - this.knownHashes.set(file.path, hash); - - const key = this.cacheKey(hash); - const cached = this.cache.get(key); - if (cached) return cached; - - const html = await this.userWorker.render( - file, - oldContent, - newContent, - this.renderOptions, - ); - this.cacheSet(key, html); - return html; - } - - // ── Background preload (bulk git read + worker pool) ─────────── - - async preloadAll() { - this.aborted = false; - const allFiles = this.getFiles(); - if (allFiles.length === 0) return; - - // Reorder: start from current file position, then wrap around - let files = allFiles; - if (this.currentFile) { - const idx = allFiles.findIndex((f) => f.path === this.currentFile); - if (idx > 0) { - files = [...allFiles.slice(idx), ...allFiles.slice(0, idx)]; - } - } - - // Bulk-read all file contents via git cat-file --batch (~30× faster) - const bulkContents = await bulkGetAllContents(files, this.diffArgs, this.cwd); - if (this.aborted) return; - - // Preload languages in all workers based on file extensions in the diff - const extensions = new Set(); - for (const file of files) { - const ext = file.path.split(".").pop()?.toLowerCase(); - if (ext) extensions.add(ext); - } - const EXTENSION_TO_LANG: Record = { - ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", - py: "python", rs: "rust", go: "go", rb: "ruby", - java: "java", kt: "kotlin", swift: "swift", c: "c", cpp: "cpp", - h: "c", hpp: "cpp", cs: "csharp", php: "php", - sh: "bash", zsh: "bash", bash: "bash", - json: "json", yaml: "yaml", yml: "yaml", toml: "toml", - md: "markdown", mdx: "mdx", - css: "css", scss: "scss", html: "html", svelte: "svelte", vue: "vue", - sql: "sql", graphql: "graphql", proto: "protobuf", - dockerfile: "dockerfile", tf: "hcl", - }; - const langs = [...extensions] - .map((ext) => EXTENSION_TO_LANG[ext]) - .filter(Boolean); - if (langs.length > 0) { - for (const w of this.preloadWorkers) w.preloadLangs(langs); - this.userWorker.preloadLangs(langs); - } - - // Build work queue - type WorkItem = { - file: ChangedFile; - oldContent: string | null; - newContent: string | null; - key: string; - }; - const queue: WorkItem[] = []; - - for (const file of files) { - const raw = bulkContents.get(file.path); - if (!raw) continue; - - // Cache raw contents so user requests don't need git show - this.contentsCache.set(file.path, raw); - - let { oldContent, newContent } = raw; - ({ oldContent, newContent } = applyWhitespace( - oldContent, newContent, this.renderOptions.ignoreWhitespace, - )); - const hash = contentHash(oldContent, newContent); - this.knownHashes.set(file.path, hash); - - const key = this.cacheKey(hash); - if (this.cache.has(key)) continue; - - queue.push({ file, oldContent, newContent, key }); - } - - if (queue.length === 0 || this.aborted) return; - - // Distribute work across the worker pool using a shared index - let nextIdx = 0; - - const workerLoop = async (worker: SSRWorker) => { - while (!this.aborted) { - const idx = nextIdx++; - if (idx >= queue.length) break; - - const item = queue[idx]; - try { - const html = await worker.render( - item.file, - item.oldContent, - item.newContent, - this.renderOptions, - ); - if (!this.aborted) { - this.cacheSet(item.key, html); - } - } catch { - // Worker error — skip this file - } - } - }; - - await Promise.all(this.preloadWorkers.map((w) => workerLoop(w))); - } - - // ── Options / refresh ───────────────────────────────────────── - - setOptions(opts: Partial) { - const prev = optionsKey(this.renderOptions); - Object.assign(this.renderOptions, opts); - if (optionsKey(this.renderOptions) !== prev) { - this.aborted = true; - setTimeout(() => this.preloadAll().catch(() => {}), 50); - } - } - - refresh() { - this.aborted = true; - this.filesCache = null; - this.filesByPath.clear(); - this.knownHashes.clear(); - this.contentsCache.clear(); - this.repoName = getRepoName(this.cwd); - this.branch = getCurrentBranch(this.cwd); - setTimeout(() => this.preloadAll().catch(() => {}), 50); - } - - preloadStatus(): { cached: number; total: number; preloading: boolean } { - // Throttle: recompute at most every 100ms - const now = Date.now(); - if (this._preloadStatusCache && now - this._preloadStatusTs < 100) { - return this._preloadStatusCache; - } - const files = this.getFiles(); - const optsKey = optionsKey(this.renderOptions); - let cached = 0; - for (const file of files) { - const hash = this.knownHashes.get(file.path); - if (hash && this.cache.has(hash + "\0" + optsKey)) cached++; - } - this._preloadStatusCache = { - cached, - total: files.length, - preloading: !this.aborted && cached < files.length, - }; - this._preloadStatusTs = now; - return this._preloadStatusCache; - } - - destroy() { - this.aborted = true; - this.userWorker.kill(); - for (const w of this.preloadWorkers) w.kill(); - } -} diff --git a/extensions/piff/git.ts b/extensions/piff/git.ts deleted file mode 100644 index 688145a..0000000 --- a/extensions/piff/git.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { execFileSync, spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -const MAX_BUF = 50 * 1024 * 1024; - -export type FileStatus = "added" | "deleted" | "modified" | "renamed" | "copied"; - -export interface ChangedFile { - path: string; - oldPath?: string; - status: FileStatus; - untracked?: boolean; - additions: number; - deletions: number; -} - -function git(args: string[], cwd: string): string { - return execFileSync("git", args, { - cwd, encoding: "utf8", maxBuffer: MAX_BUF, stdio: ["pipe", "pipe", "pipe"], - }).trim(); -} - -function gitSafe(args: string[], cwd: string): string { - try { return git(args, cwd); } catch { return ""; } -} - -export function isGitRepo(cwd: string): boolean { - try { git(["rev-parse", "--is-inside-work-tree"], cwd); return true; } catch { return false; } -} - -export function getRepoRoot(cwd: string): string { - return git(["rev-parse", "--show-toplevel"], cwd); -} - -export function getRepoName(cwd: string): string { - return getRepoRoot(cwd).split("/").pop() || "unknown"; -} - -export function getCurrentBranch(cwd: string): string { - try { return git(["branch", "--show-current"], cwd); } catch { return git(["rev-parse", "--short", "HEAD"], cwd); } -} - -export function isValidGitRef(ref: string, cwd: string): boolean { - if (ref.includes("..")) return ref.split("..").every(p => p === "" || isValidGitRef(p, cwd)); - try { git(["rev-parse", "--verify", ref], cwd); return true; } catch { return false; } -} - -/** - * Parse CLI/command refs into diffArgs + description. - * Returns `{ error }` if any ref is invalid. - */ -export function parseRefs( - refs: string[], - cwd: string, -): { diffArgs: string[]; description: string } | { error: string } { - for (const ref of refs) { - if (!isValidGitRef(ref, cwd)) return { error: ref }; - } - - if (refs.length === 1) { - return { - diffArgs: [refs[0]], - description: refs[0].includes("..") ? refs[0] : "Changes from " + refs[0], - }; - } - if (refs.length === 2) { - return { - diffArgs: [refs[0] + ".." + refs[1]], - description: refs[0] + ".." + refs[1], - }; - } - return { diffArgs: [], description: "Working tree changes" }; -} - -const STATUS_MAP: Record = { - A: "added", D: "deleted", M: "modified", R: "renamed", C: "copied", T: "modified", -}; - -function parseNumstat(raw: string): Map { - const m = new Map(); - for (const line of raw.split("\n")) { - if (!line.trim()) continue; - const [a, d, ...rest] = line.split("\t"); - const path = rest.join("\t"); - if (!path) continue; - m.set(path, { add: a === "-" ? 0 : +a, del: d === "-" ? 0 : +d }); - } - return m; -} - -export function getChangedFiles(diffArgs: string[], cwd: string): ChangedFile[] { - const files: ChangedFile[] = []; - - if (diffArgs.length === 0) { - const staged = gitSafe(["diff", "--cached", "--name-status", "--no-renames"], cwd); - const unstaged = gitSafe(["diff", "--name-status", "--no-renames"], cwd); - const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd); - const stagedNum = parseNumstat(gitSafe(["diff", "--cached", "--numstat", "--no-renames"], cwd)); - const unstagedNum = parseNumstat(gitSafe(["diff", "--numstat", "--no-renames"], cwd)); - - const seen = new Set(); - for (const line of [...staged.split("\n"), ...unstaged.split("\n")]) { - if (!line.trim()) continue; - const [code, ...rest] = line.split("\t"); - const path = rest[rest.length - 1]; - if (!path || seen.has(path)) continue; - seen.add(path); - const sn = stagedNum.get(path); - const un = unstagedNum.get(path); - files.push({ - path, - status: STATUS_MAP[code[0]] || "modified", - additions: (sn?.add || 0) + (un?.add || 0), - deletions: (sn?.del || 0) + (un?.del || 0), - }); - } - - for (const p of untracked.split("\n")) { - if (!p.trim() || seen.has(p)) continue; - seen.add(p); - let lines = 0; - try { - const content = readFileSync(join(cwd, p), "utf-8"); - lines = content.split("\n").length; - if (content.endsWith("\n")) lines--; - } catch {} - files.push({ path: p, status: "added", untracked: true, additions: lines, deletions: 0 }); - } - } else { - const out = gitSafe(["diff", "--name-status", ...diffArgs], cwd); - const numstat = parseNumstat(gitSafe(["diff", "--numstat", ...diffArgs], cwd)); - - for (const line of out.split("\n")) { - if (!line.trim()) continue; - const [code, ...rest] = line.split("\t"); - const path = rest[rest.length - 1]; - const oldPath = rest.length > 1 ? rest[0] : undefined; - if (!path) continue; - const ns = numstat.get(path); - files.push({ - path, oldPath, - status: STATUS_MAP[code[0]] || "modified", - additions: ns?.add || 0, - deletions: ns?.del || 0, - }); - } - } - - return files.sort((a, b) => a.path.localeCompare(b.path)); -} - -export function getFileAtRef(ref: string, filePath: string, cwd: string): string | null { - try { - return execFileSync("git", ["show", `${ref}:${filePath}`], { - cwd, encoding: "utf8", maxBuffer: MAX_BUF, - stdio: ["pipe", "pipe", "pipe"], - }); - } catch { - return null; - } -} - -export function readWorkingFile(filePath: string, cwd: string): string | null { - try { - return readFileSync(join(cwd, filePath), "utf-8"); - } catch { - return null; - } -} - -export function getOldNewContents( - file: ChangedFile, - diffArgs: string[], - cwd: string, -): { oldContent: string | null; newContent: string | null } { - let oldRef = "HEAD"; - let newRef: string | null = null; // null → working tree - - if (diffArgs.length > 0) { - const arg = diffArgs[0]; - if (arg.includes("..")) { - const [a, b] = arg.split(".."); - oldRef = a || "HEAD"; - newRef = b || "HEAD"; - } else { - oldRef = arg; - } - } - - let oldContent: string | null = null; - let newContent: string | null = null; - - if (file.status !== "added" || !file.untracked) { - oldContent = getFileAtRef(oldRef, file.oldPath || file.path, cwd); - } - - if (file.status !== "deleted") { - newContent = newRef - ? getFileAtRef(newRef, file.path, cwd) - : readWorkingFile(file.path, cwd); - } - - return { oldContent, newContent }; -} - -/** Fast hash of old+new content pair, used as cache key. Uses Bun.hash (wyhash). */ -export function contentHash( - oldContent: string | null, - newContent: string | null, -): string { - // Hash each part separately to avoid concatenating large strings. - // Mix h1 into h2's seed so collisions require both parts to collide simultaneously. - const h1 = Bun.hash(oldContent ?? "\0NULL\0"); - const h2 = Bun.hash(newContent ?? "\0NULL\0", Number(h1 & 0xFFFFFFFFn)); - return h1.toString(36) + "_" + h2.toString(36); -} - -/** - * Bulk-read file contents at a given ref using `git cat-file --batch`. - * Returns a Map from "ref:path" → content string (missing entries = file not found). - * ~30× faster than sequential `git show` for many files. - */ -export function bulkGetFilesAtRef( - ref: string, - paths: string[], - cwd: string, -): Promise> { - return new Promise((resolve) => { - if (paths.length === 0) { - resolve(new Map()); - return; - } - - const proc = spawn("git", ["cat-file", "--batch"], { - cwd, - stdio: ["pipe", "pipe", "pipe"], - }); - - const results = new Map(); - let buf = Buffer.alloc(0); - let bufOffset = 0; // track consumed bytes to avoid repeated subarray - let pendingIdx = 0; - - // Each response from cat-file --batch is: - // \n\n (for found objects) - // missing\n (for missing objects) - const processBuffer = () => { - while (pendingIdx < paths.length) { - // Need at least one line (header) - const nlIdx = buf.indexOf(10, bufOffset); // \n - if (nlIdx === -1) break; - - const headerLine = buf.subarray(bufOffset, nlIdx).toString("utf8"); - const query = `${ref}:${paths[pendingIdx]}`; - - if (headerLine.endsWith(" missing")) { - results.set(query, null); - bufOffset = nlIdx + 1; - pendingIdx++; - continue; - } - - // Parse: " blob " - const sizeMatch = headerLine.match(/\s(\d+)$/); - if (!sizeMatch) { - // Unexpected format, skip - results.set(query, null); - bufOffset = nlIdx + 1; - pendingIdx++; - continue; - } - - const size = parseInt(sizeMatch[1], 10); - const contentStart = nlIdx + 1; - const contentEnd = contentStart + size; - // Need size bytes + trailing \n - if (buf.length < contentEnd + 1) break; - - results.set(query, buf.subarray(contentStart, contentEnd).toString("utf8")); - bufOffset = contentEnd + 1; - pendingIdx++; - } - // Compact buffer when we've consumed a lot - if (bufOffset > 65536) { - buf = buf.subarray(bufOffset); - bufOffset = 0; - } - }; - - proc.stdout.on("data", (chunk: Buffer) => { - buf = bufOffset > 0 - ? Buffer.concat([buf.subarray(bufOffset), chunk]) - : Buffer.concat([buf, chunk]); - bufOffset = 0; - processBuffer(); - }); - - proc.on("close", () => { - processBuffer(); - // Mark any remaining as null - for (let i = pendingIdx; i < paths.length; i++) { - results.set(`${ref}:${paths[i]}`, null); - } - resolve(results); - }); - - proc.stderr.on("data", () => {}); - - for (const p of paths) { - proc.stdin.write(`${ref}:${p}\n`); - } - proc.stdin.end(); - }); -} - -/** - * Bulk-read old+new contents for all changed files. - * Uses `git cat-file --batch` for ref-based reads (30× faster than sequential git show). - */ -export async function bulkGetAllContents( - files: ChangedFile[], - diffArgs: string[], - cwd: string, -): Promise> { - let oldRef = "HEAD"; - let newRef: string | null = null; - - if (diffArgs.length > 0) { - const arg = diffArgs[0]; - if (arg.includes("..")) { - const [a, b] = arg.split(".."); - oldRef = a || "HEAD"; - newRef = b || "HEAD"; - } else { - oldRef = arg; - } - } - - // Collect paths to read from each ref - const oldPaths: string[] = []; - const newRefPaths: string[] = []; - for (const file of files) { - if (file.status !== "added" || !file.untracked) { - oldPaths.push(file.oldPath || file.path); - } - if (file.status !== "deleted" && newRef) { - newRefPaths.push(file.path); - } - } - - // Bulk-read from git refs in parallel - const [oldResults, newRefResults] = await Promise.all([ - bulkGetFilesAtRef(oldRef, oldPaths, cwd), - newRef ? bulkGetFilesAtRef(newRef, newRefPaths, cwd) : Promise.resolve(new Map()), - ]); - - // Assemble results - const result = new Map(); - for (const file of files) { - let oldContent: string | null = null; - let newContent: string | null = null; - - if (file.status !== "added" || !file.untracked) { - const key = `${oldRef}:${file.oldPath || file.path}`; - oldContent = oldResults.get(key) ?? null; - } - - if (file.status !== "deleted") { - if (newRef) { - const key = `${newRef}:${file.path}`; - newContent = newRefResults.get(key) ?? null; - } else { - newContent = readWorkingFile(file.path, cwd); - } - } - - result.set(file.path, { oldContent, newContent }); - } - - return result; -} - - diff --git a/extensions/piff/index.ts b/extensions/piff/index.ts deleted file mode 100644 index 193e31e..0000000 --- a/extensions/piff/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Piff – Git diff viewer extension for pi. - * - * Opens a diff viewer in the browser (plain HTML, no build step). - * - * Commands: - * /piff → working tree changes (staged + unstaged + untracked) - * /piff main → compare current branch against main - * /piff HEAD~1 → review last commit - * /piff main..feat → compare two branches - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { isGitRepo, parseRefs, getRepoRoot } from "./git.js"; -import { startServer, stopServer } from "./server.js"; - -const PORT = 5491; - -export default function (pi: ExtensionAPI) { - pi.registerCommand("piff", { - description: "Open git diff viewer (/piff [ref] [ref2])", - handler: async (args, ctx) => { - if (!isGitRepo(ctx.cwd)) { - ctx.ui.notify("Not a git repository", "error"); - return; - } - - const refs = args ? args.trim().split(/\s+/).filter(Boolean) : []; - const parsed = parseRefs(refs, ctx.cwd); - if ("error" in parsed) { - ctx.ui.notify("Invalid git ref: '" + parsed.error + "'", "error"); - return; - } - - const { diffArgs, description } = parsed; - - stopServer(); - - try { - await startServer({ - port: PORT, - diffArgs, - description, - cwd: getRepoRoot(ctx.cwd), - }); - ctx.ui.notify("piff → http://localhost:" + PORT); - } catch (err) { - ctx.ui.notify("Failed to start piff: " + err, "error"); - } - }, - }); - - pi.on("session_shutdown", stopServer); -} diff --git a/extensions/piff/lru-cache.ts b/extensions/piff/lru-cache.ts deleted file mode 100644 index e971af9..0000000 --- a/extensions/piff/lru-cache.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Simple LRU cache with a max-entry limit. - * Uses a Map (insertion-ordered) — on get/set the entry is - * moved to the end so the oldest entry is always first. - */ -export class LRUCache { - private map = new Map(); - - constructor(private maxSize: number) {} - - get(key: K): V | undefined { - const val = this.map.get(key); - if (val === undefined) return undefined; - // Move to end (most recently used) - this.map.delete(key); - this.map.set(key, val); - return val; - } - - has(key: K): boolean { - return this.map.has(key); - } - - set(key: K, val: V): void { - if (this.map.has(key)) this.map.delete(key); - this.map.set(key, val); - while (this.map.size > this.maxSize) { - // Delete oldest (first) entry - const first = this.map.keys().next().value!; - this.map.delete(first); - } - } - - get size(): number { - return this.map.size; - } - - clear(): void { - this.map.clear(); - } -} diff --git a/extensions/piff/package-lock.json b/extensions/piff/package-lock.json deleted file mode 100644 index 2008d02..0000000 --- a/extensions/piff/package-lock.json +++ /dev/null @@ -1,2338 +0,0 @@ -{ - "name": "piff-deps", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "piff-deps", - "dependencies": { - "@pierre/diffs": "latest" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^7.0.0", - "svelte": "^5.55.0", - "vite": "^8.0.2", - "vite-plugin-singlefile": "^2.3.2" - } - }, - "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@pierre/diffs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.3.tgz", - "integrity": "sha512-sV6G1FL0L4UtequXi+50Uge4QVsouo5vgJx7pCawQ4+ctFglh03zIsB81W8PNheh3coIlVzLzFgB9kI7X4eyjw==", - "license": "apache-2.0", - "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" - } - }, - "node_modules/@pierre/theme": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-0.0.22.tgz", - "integrity": "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA==", - "license": "MIT", - "engines": { - "vscode": "^1.0.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", - "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", - "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", - "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@shikijs/core": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", - "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", - "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", - "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", - "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@shikijs/themes": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", - "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@shikijs/transformers": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz", - "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "3.23.0", - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@shikijs/types": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", - "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "license": "MIT" - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", - "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deepmerge": "^4.3.1", - "magic-string": "^0.30.21", - "obug": "^2.1.0", - "vitefu": "^1.1.2" - }, - "engines": { - "node": "^20.19 || ^22.12 || >=24" - }, - "peerDependencies": { - "svelte": "^5.46.4", - "vite": "^8.0.0-beta.7 || ^8.0.0" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "license": "ISC" - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", - "dev": true, - "license": "MIT" - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", - "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", - "@typescript-eslint/types": "^8.2.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "license": "MIT", - "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" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru_map": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", - "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "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" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/oniguruma-parser": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", - "license": "MIT" - }, - "node_modules/oniguruma-to-es": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", - "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", - "license": "MIT", - "dependencies": { - "oniguruma-parser": "^0.12.1", - "regex": "^6.1.0", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "license": "MIT" - }, - "node_modules/rolldown": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", - "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.11" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-x64": "1.0.0-rc.11", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" - } - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true - }, - "node_modules/shiki": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", - "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "3.23.0", - "@shikijs/engine-javascript": "3.23.0", - "@shikijs/engine-oniguruma": "3.23.0", - "@shikijs/langs": "3.23.0", - "@shikijs/themes": "3.23.0", - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/svelte": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", - "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.6.4", - "esm-env": "^1.2.1", - "esrap": "^2.2.2", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", - "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.11", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", - "jiti": ">=1.21.0", - "less": "^4.0.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" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-plugin-singlefile": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.2.tgz", - "integrity": "sha512-b8SxCi/gG7K298oJDcKOuZeU6gf6wIcCJAaEqUmmZXdjfuONlkyNyWZC3tEbN6QockRCNUd3it9eGTtpHGoYmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">18.0.0" - }, - "peerDependencies": { - "rollup": "^4.59.0", - "vite": "^5.4.11 || ^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/vitefu": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", - "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/extensions/piff/package.json b/extensions/piff/package.json deleted file mode 100644 index 689d444..0000000 --- a/extensions/piff/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "piff-deps", - "private": true, - "scripts": { - "build": "vite build", - "dev": "bun --watch cli.ts & bun ./node_modules/.bin/vite dev & wait" - }, - "dependencies": { - "@pierre/diffs": "latest" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^7.0.0", - "svelte": "^5.55.0", - "vite": "^8.0.2", - "vite-plugin-singlefile": "^2.3.2" - } -} diff --git a/extensions/piff/server.ts b/extensions/piff/server.ts deleted file mode 100644 index 94304ab..0000000 --- a/extensions/piff/server.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { createServer, type ServerResponse } from "node:http"; -import { readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { DiffSession } from "./diff-session.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const PAGE_HTML = readFileSync(join(__dirname, "dist", "index.html"), "utf-8"); -const PAGE_HTML_BUF = Buffer.from(PAGE_HTML, "utf-8"); -const PAGE_ETAG = `"${Bun.hash(PAGE_HTML).toString(36)}"`; - - -export interface ServerOptions { - port: number; - diffArgs: string[]; - description: string; - cwd: string; -} - -let activeServer: ReturnType | null = null; -let activeSession: DiffSession | null = null; - -export function stopServer() { - activeSession?.destroy(); - activeSession = null; - if (activeServer) { - try { - activeServer.close(); - } catch {} - activeServer = null; - } -} - -async function killPort(port: number) { - try { - const proc = Bun.spawn(["lsof", "-ti:" + port], { stdout: "pipe", stderr: "ignore" }); - const text = await new Response(proc.stdout).text(); - for (const pid of text.trim().split("\n").filter(Boolean)) { - try { process.kill(+pid, "SIGKILL"); } catch {} - } - } catch {} -} - -export async function startServer(opts: ServerOptions): Promise { - const { port, diffArgs, description, cwd } = opts; - stopServer(); - await killPort(port); - - const session = new DiffSession(diffArgs, cwd); - activeSession = session; - session.preloadAll().catch(() => {}); - - const json = (res: ServerResponse, data: unknown) => { - res.writeHead(200, { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - }); - res.end(JSON.stringify(data)); - }; - - const error = (res: ServerResponse, code: number, msg: string) => { - res.writeHead(code, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: msg })); - }; - - return new Promise((resolve, reject) => { - const s = createServer(async (req, res) => { - try { - const p = new URL(req.url || "/", "http://x").pathname; - - if (req.method === "OPTIONS") { - res.writeHead(204, { "Access-Control-Allow-Origin": "*" }); - res.end(); - return; - } - - if (p === "/api/info") { - const files = session.getFiles(); - json(res, { - name: session.repoName, - branch: session.branch, - description, - fileCount: files.length, - cached: session.cachedCount, - total: files.length, - }); - return; - } - - if (p === "/api/files") { - json(res, session.getFiles()); - return; - } - - if (p === "/api/options") { - if (req.method === "GET") { - json(res, session.renderOptions); - return; - } - let body = ""; - req.on("data", (c: Buffer) => { - body += c; - }); - req.on("end", () => { - try { - const opts = JSON.parse(body); - session.setOptions(opts); - json(res, session.renderOptions); - } catch { - error(res, 400, "Invalid JSON"); - } - }); - return; - } - - if (p === "/api/refresh") { - session.refresh(); - json(res, { ok: true }); - return; - } - - if (p === "/api/preload-status") { - json(res, session.preloadStatus()); - return; - } - - if (p === "/api/preload-stream") { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", - }); - - const send = () => { - const status = session.preloadStatus(); - res.write(`data: ${JSON.stringify(status)}\n\n`); - if (!status.preloading) { - clearInterval(timer); - res.end(); - } - }; - - send(); - const timer = setInterval(send, 300); - - req.on("close", () => clearInterval(timer)); - return; - } - - if (p.startsWith("/api/diff/")) { - const filePath = decodeURIComponent(p.slice(10)); - const file = session.getFileByPath(filePath); - if (!file) { - error(res, 404, "Not found"); - return; - } - try { - const html = await session.getDiffHTMLForUser(file); - json(res, { file, html }); - } catch (e) { - error(res, 500, String(e)); - } - return; - } - - // Serve the SPA with ETag caching - if (req.headers["if-none-match"] === PAGE_ETAG) { - res.writeHead(304); - res.end(); - return; - } - res.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "ETag": PAGE_ETAG, - "Cache-Control": "no-cache", - }); - res.end(PAGE_HTML_BUF); - } catch (e) { - if (!res.headersSent) { - error(res, 500, String(e)); - } - } - }); - - activeServer = s; - s.on("error", (e: NodeJS.ErrnoException) => - reject( - e.code === "EADDRINUSE" - ? new Error("Port " + port + " in use") - : e, - ), - ); - s.listen(port, "127.0.0.1", () => resolve(port)); - }); -} diff --git a/extensions/piff/src/App.svelte b/extensions/piff/src/App.svelte deleted file mode 100644 index 0a86883..0000000 --- a/extensions/piff/src/App.svelte +++ /dev/null @@ -1,281 +0,0 @@ - - - - -
- -
- - -
-
- - - - - - diff --git a/extensions/piff/src/components/DiffView.svelte b/extensions/piff/src/components/DiffView.svelte deleted file mode 100644 index 93e3be9..0000000 --- a/extensions/piff/src/components/DiffView.svelte +++ /dev/null @@ -1,388 +0,0 @@ - - -
-
-
- {hunkBadgeText} -
- - {#if showSpinner} -
- Loading… -
- {:else if errorMsg} -
{errorMsg}
- {:else if !currentHtml && !$selectedPath} -
Select a file
- {/if} - -
-
- - diff --git a/extensions/piff/src/components/HelpOverlay.svelte b/extensions/piff/src/components/HelpOverlay.svelte deleted file mode 100644 index b1b2d86..0000000 --- a/extensions/piff/src/components/HelpOverlay.svelte +++ /dev/null @@ -1,120 +0,0 @@ - - - - -
-
-

Keyboard Shortcuts

- -
-

Files

-
j / ↓Next file
-
k / ↑Previous file
-
JLast file
-
KFirst file
-
yCopy file path
-
bToggle sidebar
-
h / lCollapse / expand folder
-
H / LCollapse / expand all
-
- -
-

Diff navigation

-
nNext changed hunk
-
NPrevious changed hunk
-
ggScroll to top
-
GScroll to bottom
-
Ctrl+d / uScroll ½ page down / up
-
Ctrl+f / bScroll full page down / up
-
- -
-

Display

-
sToggle split / unified
-
wToggle ignore whitespace
-
eToggle expand unchanged
-
- -
-

General

-
?Toggle this help
-
- -
Press ? or Esc to close
-
-
- - diff --git a/extensions/piff/src/components/Sidebar.svelte b/extensions/piff/src/components/Sidebar.svelte deleted file mode 100644 index 3e5d890..0000000 --- a/extensions/piff/src/components/Sidebar.svelte +++ /dev/null @@ -1,385 +0,0 @@ - - - - - diff --git a/extensions/piff/src/components/Toast.svelte b/extensions/piff/src/components/Toast.svelte deleted file mode 100644 index d6bb414..0000000 --- a/extensions/piff/src/components/Toast.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - -
- {$toastMessage ?? ""} -
- - diff --git a/extensions/piff/src/components/Toolbar.svelte b/extensions/piff/src/components/Toolbar.svelte deleted file mode 100644 index b2f7985..0000000 --- a/extensions/piff/src/components/Toolbar.svelte +++ /dev/null @@ -1,243 +0,0 @@ - - -
- -
- {$repoInfo?.name ?? ""} - {$repoInfo?.branch ?? ""} -
- - │ - - -
- {$files.length} file{$files.length !== 1 ? "s" : ""}{#if $totalAdditions > 0}{" "}+{$totalAdditions}{/if}{#if $totalDeletions > 0}{" "}−{$totalDeletions}{/if} -
- - {#if $preloadStatus.total > 0} - │ - - - - {#if $preloadStatus.preloading} - - {:else} - ✓ - {/if} - cache {$preloadStatus.cached}/{$preloadStatus.total} - - {/if} - - │ - - -
- - - -
- - │ - - -
- - -
-
- - diff --git a/extensions/piff/src/index.html b/extensions/piff/src/index.html deleted file mode 100644 index d0383af..0000000 --- a/extensions/piff/src/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - piff - - -
- - - diff --git a/extensions/piff/src/lib/api.ts b/extensions/piff/src/lib/api.ts deleted file mode 100644 index 99a5fca..0000000 --- a/extensions/piff/src/lib/api.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ChangedFile, RepoInfo, RenderOptions } from "./state"; - -export async function fetchInfo(): Promise { - const r = await fetch("/api/info"); - return r.json(); -} - -export async function fetchFiles(): Promise { - const r = await fetch("/api/files"); - return r.json(); -} - -export async function fetchDiff( - path: string, - signal?: AbortSignal -): Promise<{ file: ChangedFile; html: string } | null> { - const r = await fetch("/api/diff/" + encodeURIComponent(path), { signal }); - if (!r.ok) { - const d = await r.json().catch(() => ({})); - throw new Error((d as any).error || "HTTP " + r.status); - } - return r.json(); -} - -export async function fetchOptions(): Promise { - const r = await fetch("/api/options"); - return r.json(); -} - -export async function updateOptions( - opts: Partial -): Promise { - const r = await fetch("/api/options", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(opts), - }); - return r.json(); -} - -export async function refresh(): Promise { - await fetch("/api/refresh"); -} - - - - diff --git a/extensions/piff/src/lib/keyboard.ts b/extensions/piff/src/lib/keyboard.ts deleted file mode 100644 index 0022dfd..0000000 --- a/extensions/piff/src/lib/keyboard.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Keyboard handler - dispatches actions based on key presses. - * Returns an action descriptor so the caller (App.svelte) can handle side effects. - */ - -export type Action = - | { type: "nextFile" } - | { type: "prevFile" } - | { type: "firstFile" } - | { type: "lastFile" } - | { type: "copyPath" } - | { type: "toggleSidebar" } - | { type: "collapseFolder" } - | { type: "expandFolder" } - | { type: "collapseAll" } - | { type: "expandAll" } - | { type: "nextHunk" } - | { type: "prevHunk" } - | { type: "scrollTop" } - | { type: "scrollBottom" } - | { type: "scrollHalfDown" } - | { type: "scrollHalfUp" } - | { type: "scrollPageDown" } - | { type: "scrollPageUp" } - | { type: "toggleStyle" } - | { type: "toggleWhitespace" } - | { type: "toggleExpand" } - | { type: "toggleHelp" } - | null; - -let pendingG = false; - -export function handleKeydown(e: KeyboardEvent): Action { - if ( - (e.target as HTMLElement).tagName === "INPUT" || - (e.target as HTMLElement).tagName === "TEXTAREA" - ) - return null; - - if (e.key === "?") { - e.preventDefault(); - return { type: "toggleHelp" }; - } - - if (e.key === "Escape") { - e.preventDefault(); - return { type: "toggleHelp" }; // App will check if help is visible - } - - if (e.ctrlKey) { - switch (e.key) { - case "d": e.preventDefault(); return { type: "scrollHalfDown" }; - case "u": e.preventDefault(); return { type: "scrollHalfUp" }; - case "f": e.preventDefault(); return { type: "scrollPageDown" }; - case "b": e.preventDefault(); return { type: "scrollPageUp" }; - } - return null; - } - - // gg sequence - if (e.key === "g" && !e.ctrlKey && !e.metaKey && !e.shiftKey) { - if (pendingG) { - e.preventDefault(); - pendingG = false; - return { type: "scrollTop" }; - } - pendingG = true; - setTimeout(() => { pendingG = false; }, 500); - return null; - } - pendingG = false; - - switch (e.key) { - case "G": e.preventDefault(); return { type: "scrollBottom" }; - case "n": e.preventDefault(); return { type: "nextHunk" }; - case "N": e.preventDefault(); return { type: "prevHunk" }; - case "s": e.preventDefault(); return { type: "toggleStyle" }; - case "w": e.preventDefault(); return { type: "toggleWhitespace" }; - case "e": e.preventDefault(); return { type: "toggleExpand" }; - case "y": e.preventDefault(); return { type: "copyPath" }; - case "b": e.preventDefault(); return { type: "toggleSidebar" }; - case "h": e.preventDefault(); return { type: "collapseFolder" }; - case "l": e.preventDefault(); return { type: "expandFolder" }; - case "H": e.preventDefault(); return { type: "collapseAll" }; - case "L": e.preventDefault(); return { type: "expandAll" }; - case "j": case "ArrowDown": e.preventDefault(); return { type: "nextFile" }; - case "k": case "ArrowUp": e.preventDefault(); return { type: "prevFile" }; - case "K": e.preventDefault(); return { type: "firstFile" }; - case "J": e.preventDefault(); return { type: "lastFile" }; - } - - return null; -} diff --git a/extensions/piff/src/lib/state.ts b/extensions/piff/src/lib/state.ts deleted file mode 100644 index 470fc6a..0000000 --- a/extensions/piff/src/lib/state.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { writable, derived } from "svelte/store"; - -export interface ChangedFile { - path: string; - oldPath?: string; - status: "added" | "deleted" | "modified" | "renamed" | "copied"; - untracked?: boolean; - additions: number; - deletions: number; -} - -export interface RepoInfo { - name: string; - branch: string; - description: string; - fileCount: number; - cached: number; - total: number; -} - -export interface RenderOptions { - diffStyle: "split" | "unified"; - expandUnchanged: boolean; - ignoreWhitespace: boolean; -} - -export const files = writable([]); -export const repoInfo = writable(null); -export const selectedPath = writable(null); -export const renderOptions = writable({ - diffStyle: "split", - expandUnchanged: true, - ignoreWhitespace: false, -}); -export const helpVisible = writable(false); -export const sidebarVisible = writable(true); -export const toastMessage = writable(null); -export const preloadStatus = writable<{ cached: number; total: number; preloading: boolean }>({ - cached: 0, - total: 0, - preloading: false, -}); - -export const totalAdditions = derived(files, ($files) => - $files.reduce((sum, f) => sum + f.additions, 0) -); -export const totalDeletions = derived(files, ($files) => - $files.reduce((sum, f) => sum + f.deletions, 0) -); diff --git a/extensions/piff/src/main.ts b/extensions/piff/src/main.ts deleted file mode 100644 index b1f2754..0000000 --- a/extensions/piff/src/main.ts +++ /dev/null @@ -1,6 +0,0 @@ -import App from "./App.svelte"; -import { mount } from "svelte"; - -const app = mount(App, { target: document.getElementById("app")! }); - -export default app; diff --git a/extensions/piff/ssr-worker-client.ts b/extensions/piff/ssr-worker-client.ts deleted file mode 100644 index e7efbaa..0000000 --- a/extensions/piff/ssr-worker-client.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawn, type ChildProcess } from "node:child_process"; -import type { ChangedFile } from "./git.js"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export interface RenderOptions { - diffStyle: "split" | "unified"; - expandUnchanged: boolean; - ignoreWhitespace: boolean; -} - -/** - * Combined line count above which we auto-optimize rendering: - * - force split view (unified is very slow for large files) - * - disable expandUnchanged (render only changed hunks) - */ -const LARGE_FILE_THRESHOLD = 3000; - -function countLines(s: string | null): number { - if (!s) return 0; - let count = 1; - let pos = 0; - while ((pos = s.indexOf("\n", pos)) !== -1) { count++; pos++; } - return count; -} - -export function stripTrailingWS(s: string | null): string | null { - if (s == null) return s; - return s.replace(/[^\S\n]+$/gm, ""); -} - -/** - * Manages a Bun subprocess that renders diffs via @pierre/diffs SSR. - * Communicates via JSON lines over stdin/stdout. - */ -export class SSRWorker { - private proc: ChildProcess | null = null; - private ready = false; - private nextId = 0; - private pending = new Map< - number, - { resolve: (html: string) => void; reject: (err: Error) => void } - >(); - private buf = ""; - private starting: Promise | null = null; - - async ensure(): Promise { - if (this.proc && this.ready) return; - if (this.starting) return this.starting; - this.starting = new Promise((resolve, reject) => { - this.proc = spawn("bun", ["run", join(__dirname, "ssr-worker.ts")], { - stdio: ["pipe", "pipe", "inherit"], - cwd: __dirname, - }); - - this.proc.stdin!.on("error", () => {}); - - this.proc.on("exit", (code) => { - this.proc = null; - this.ready = false; - this.starting = null; - this.rejectAll("SSR worker exited: " + code); - }); - - this.proc.stdout!.on("data", (chunk: Buffer) => { - this.buf += chunk.toString(); - let nl: number; - while ((nl = this.buf.indexOf("\n")) !== -1) { - const line = this.buf.slice(0, nl); - this.buf = this.buf.slice(nl + 1); - if (!line.trim()) continue; - try { - const msg = JSON.parse(line); - if (msg.id === 0 && msg.ready) { - this.ready = true; - this.starting = null; - resolve(); - continue; - } - const cb = this.pending.get(msg.id); - if (cb) { - this.pending.delete(msg.id); - msg.error - ? cb.reject(new Error(msg.error)) - : cb.resolve(msg.html); - } - } catch {} - } - }); - - setTimeout(() => { - if (!this.ready) { - this.starting = null; - reject(new Error("SSR worker timeout")); - } - }, 15_000); - }); - return this.starting; - } - - /** - * Render a diff to HTML. Caller provides file contents (so it can - * compute content hashes and manage caching externally). - */ - /** - * Send a language preload hint to the worker (fire-and-forget). - */ - preloadLangs(langs: string[]) { - if (!this.proc || !this.ready || langs.length === 0) return; - try { - this.proc.stdin!.write( - JSON.stringify({ id: -1, preloadLangs: langs }) + "\n", - ); - } catch {} - } - - async render( - file: ChangedFile, - oldContent: string | null, - newContent: string | null, - renderOpts?: RenderOptions, - ): Promise { - await this.ensure(); - const id = ++this.nextId; - - // Count combined lines to detect large files (without allocating arrays) - const lineCount = countLines(oldContent) + countLines(newContent); - const isLarge = lineCount > LARGE_FILE_THRESHOLD; - - const options: Record = {}; - if (renderOpts?.diffStyle) { - // Force split for large files (unified rendering is much slower) - options.diffStyle = isLarge ? "split" : renderOpts.diffStyle; - } - if (renderOpts?.expandUnchanged !== undefined) { - // Disable expand for large files (render only changed hunks) - options.expandUnchanged = isLarge ? false : renderOpts.expandUnchanged; - } - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - try { - this.proc!.stdin!.write( - JSON.stringify({ - id, - oldFile: { - name: file.oldPath || file.path, - contents: oldContent ?? "", - }, - newFile: { name: file.path, contents: newContent ?? "" }, - options, - }) + "\n", - ); - } catch (e) { - this.pending.delete(id); - reject(new Error("Failed to write to worker: " + e)); - } - }); - } - - kill() { - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - this.ready = false; - } - this.rejectAll("Worker killed"); - } - - private rejectAll(msg: string) { - for (const [, cb] of this.pending) cb.reject(new Error(msg)); - this.pending.clear(); - } -} diff --git a/extensions/piff/ssr-worker.ts b/extensions/piff/ssr-worker.ts deleted file mode 100644 index 16a1d8c..0000000 --- a/extensions/piff/ssr-worker.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Persistent Bun subprocess for SSR diff rendering. - * Protocol: JSON lines over stdin/stdout. - * - * Request: { "id": 1, "oldFile": { "name": "...", "contents": "..." }, "newFile": { ... }, "options": { ... } } - * Response: { "id": 1, "html": "..." } or { "id": 1, "error": "..." } - * Ready: { "id": 0, "ready": true } - * Preload: { "id": -1, "preloadLangs": ["python", "go", ...] } - */ - -import { createInterface } from "node:readline"; -import { preloadDiffHTML } from "@pierre/diffs/ssr"; -import { registerCustomTheme, preloadHighlighter } from "@pierre/diffs"; -import altCatppuccinMocha from "./alt-catppuccin-mocha.json"; - -const THEME_NAME = "alt-catppuccin-mocha"; -registerCustomTheme(THEME_NAME, async () => ({ - ...(altCatppuccinMocha as any), - name: THEME_NAME, - colors: { - ...(altCatppuccinMocha as any).colors, - "editor.background": "#15131E", - }, -})); - -const UNSAFE_CSS = ` - [data-line-type='change-addition'] { - box-shadow: inset 0 1px 0 rgba(166, 227, 161, 0.25), inset 0 -1px 0 rgba(166, 227, 161, 0.25); - } - [data-line-type='change-addition'] + [data-line-type='change-addition'] { - box-shadow: inset 0 -1px 0 rgba(166, 227, 161, 0.25); - } - [data-line-type='change-addition']:has(+ [data-line-type='change-addition']) { - box-shadow: inset 0 1px 0 rgba(166, 227, 161, 0.25); - } - [data-line-type='change-addition'] + [data-line-type='change-addition']:has(+ [data-line-type='change-addition']) { - box-shadow: none; - } - - [data-line-type='change-deletion'] { - box-shadow: inset 0 1px 0 rgba(243, 139, 168, 0.25), inset 0 -1px 0 rgba(243, 139, 168, 0.25); - } - [data-line-type='change-deletion'] + [data-line-type='change-deletion'] { - box-shadow: inset 0 -1px 0 rgba(243, 139, 168, 0.25); - } - [data-line-type='change-deletion']:has(+ [data-line-type='change-deletion']) { - box-shadow: inset 0 1px 0 rgba(243, 139, 168, 0.25); - } - [data-line-type='change-deletion'] + [data-line-type='change-deletion']:has(+ [data-line-type='change-deletion']) { - box-shadow: none; - } -`; - -const DEFAULT_OPTIONS = { - theme: THEME_NAME, - diffIndicators: "none" as const, - diffStyle: "split" as const, - expandUnchanged: true, - unsafeCSS: UNSAFE_CSS, -}; - -// Preload common languages to avoid lazy-load delays during rendering -await preloadHighlighter({ - themes: [THEME_NAME], - langs: [ - "typescript", "javascript", "tsx", "jsx", - "json", "yaml", "toml", "markdown", - "css", "html", "svelte", - "python", "go", "rust", "bash", "shell", - ], -}).catch(() => {}); - -// Warmup with a tiny diff to initialize rendering pipeline -await preloadDiffHTML({ - oldFile: { name: "warmup.ts", contents: "const x = 1;" }, - newFile: { name: "warmup.ts", contents: "const x = 2;" }, - options: DEFAULT_OPTIONS, -}).catch(() => {}); - -process.stdout.write(JSON.stringify({ id: 0, ready: true }) + "\n"); - -const rl = createInterface({ input: process.stdin }); - -for await (const line of rl) { - if (!line.trim()) continue; - let req: any; - try { - req = JSON.parse(line); - } catch { - continue; - } - - // Handle language preload requests - if (req.preloadLangs) { - preloadHighlighter({ themes: [], langs: req.preloadLangs }).catch(() => {}); - continue; - } - - (async (r: any) => { - try { - const opts = { ...DEFAULT_OPTIONS, ...r.options }; - const html = await preloadDiffHTML({ - oldFile: r.oldFile, - newFile: r.newFile, - options: opts, - }); - process.stdout.write(JSON.stringify({ id: r.id, html }) + "\n"); - } catch (e) { - process.stdout.write(JSON.stringify({ id: r.id, error: String(e) }) + "\n"); - } - })(req); -} diff --git a/extensions/piff/svelte.config.js b/extensions/piff/svelte.config.js deleted file mode 100644 index d0e6448..0000000 --- a/extensions/piff/svelte.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -export default { - preprocess: vitePreprocess(), -}; diff --git a/extensions/piff/vite.config.ts b/extensions/piff/vite.config.ts deleted file mode 100644 index 18d243c..0000000 --- a/extensions/piff/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; -import { viteSingleFile } from "vite-plugin-singlefile"; - -export default defineConfig(({ command }) => ({ - plugins: [svelte(), ...(command === "build" ? [viteSingleFile()] : [])], - root: "src", - build: { - outDir: "../dist", - emptyOutDir: true, - }, - server: { - port: 5492, - proxy: { - "/api": "http://localhost:5491", - }, - }, -})); diff --git a/extensions/review/review.ts b/extensions/review/review.ts index 5fcabe8..e79d7dc 100644 --- a/extensions/review/review.ts +++ b/extensions/review/review.ts @@ -31,6 +31,7 @@ import { DynamicBorder, BorderedLoader } from "@mariozechner/pi-coding-agent"; import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui"; import path from "node:path"; import { promises as fs } from "node:fs"; +import { buildSemReviewGuidance, getSemToolAvailability } from "./sem-guidance.mjs"; // State to track fresh session review (where we branched from). // Module-level state means only one review can be active at a time. @@ -920,12 +921,17 @@ export default function reviewExtension(pi: ExtensionAPI) { } const prompt = await buildReviewPrompt(pi, target); + const semReviewInstructions = await buildSemReviewInstructions(target); const hint = getUserFacingHint(target); const projectGuidelines = await loadProjectReviewGuidelines(ctx.cwd); // Combine the review rubric with the specific prompt let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\nPlease perform a code review with the following focus:\n\n${prompt}`; + if (semReviewInstructions) { + fullPrompt += `\n\n${semReviewInstructions}`; + } + if (projectGuidelines) { fullPrompt += `\n\nThis project has additional instructions for code reviews:\n\n${projectGuidelines}`; } @@ -1031,6 +1037,27 @@ export default function reviewExtension(pi: ExtensionAPI) { }; } + async function buildSemReviewInstructions(target: ReviewTarget): Promise { + const tools = getSemToolAvailability(pi.getActiveTools()); + if (!tools.any) return null; + + switch (target.type) { + case "baseBranch": { + const mergeBase = await getMergeBase(pi, target.branch); + return buildSemReviewGuidance({ type: "baseBranch", branch: target.branch, mergeBase }, tools); + } + case "pullRequest": { + const mergeBase = await getMergeBase(pi, target.baseBranch); + return buildSemReviewGuidance( + { type: "pullRequest", prNumber: target.prNumber, baseBranch: target.baseBranch, title: target.title, mergeBase }, + tools, + ); + } + default: + return buildSemReviewGuidance(target, tools); + } + } + // Register the /review command pi.registerCommand("review", { description: "Review code changes (PR, uncommitted, branch, commit, folder, or custom)", diff --git a/extensions/review/sem-guidance.mjs b/extensions/review/sem-guidance.mjs new file mode 100644 index 0000000..06dd3e5 --- /dev/null +++ b/extensions/review/sem-guidance.mjs @@ -0,0 +1,83 @@ +export function getSemToolAvailability(activeToolNames = []) { + const names = new Set(Array.isArray(activeToolNames) ? activeToolNames : []); + return { + diff: names.has("sem_diff"), + impact: names.has("sem_impact"), + context: names.has("sem_context"), + entities: names.has("sem_entities"), + any: names.has("sem_diff") || names.has("sem_impact") || names.has("sem_context") || names.has("sem_entities"), + }; +} + +function diffStep(target, tools) { + if (!tools.diff) return null; + + switch (target.type) { + case "uncommitted": + return "Start with `sem_diff` on the working tree to identify the changed functions, classes, blocks, and properties before reading large raw diffs."; + case "commit": + return `Start with \`sem_diff\` for commit \`${target.sha}\` to inventory changed entities and spot the riskiest touched code paths.`; + case "baseBranch": + if (target.mergeBase) { + return `Start with \`sem_diff\` over the range \`${target.mergeBase}..HEAD\` to see the semantic shape of the branch before diving into line hunks.`; + } + return `Use \`sem_diff\` once you know the merge-base range for branch \`${target.branch}\`; use it for overview, not as the only source of review evidence.`; + case "pullRequest": + if (target.mergeBase) { + return `Start with \`sem_diff\` over \`${target.mergeBase}..HEAD\` to understand the PR's changed entities before line-level verification.`; + } + return `Use \`sem_diff\` for the PR diff once the merge base is known; use it as a semantic overview, not the sole review source.`; + case "folder": + return null; + case "custom": + return "Use `sem_diff` only if the custom instructions are about change analysis or review summaries; do not default to it for snapshot/file-reading tasks."; + default: + return null; + } +} + +function contextStep(target, tools) { + if (!tools.context) return null; + if (target.type === "folder") { + return "Prefer `sem_context` when one function/class inside the reviewed files looks suspicious and you need compact, dependency-aware context instead of reading whole large files."; + } + return "Prefer `sem_context` for the specific changed entity you are reasoning about. Use it before loading whole large files when you need focused context around a function, method, class, or block."; +} + +function impactStep(tools) { + if (!tools.impact) return null; + return "Use `sem_impact` on 1-3 risky changed entities when you suspect hidden blast radius, affected tests, or cross-module consequences. Prefer `scope=tests` for test selection and `scope=all` when validating broader impact."; +} + +function entitiesStep(target, tools) { + if (!tools.entities) return null; + if (target.type === "folder") { + return "Use `sem_entities` to inventory the reviewed files and pick the highest-value functions/classes/blocks before deeper inspection."; + } + return "Use `sem_entities` if you need a quick structural inventory of a changed file before drilling into a suspicious entity."; +} + +export function buildSemReviewGuidance(target, toolsInput) { + const tools = toolsInput && typeof toolsInput === "object" && toolsInput.any !== undefined + ? toolsInput + : getSemToolAvailability([]); + + if (!tools.any) return null; + + const steps = [ + diffStep(target, tools), + impactStep(tools), + contextStep(target, tools), + entitiesStep(target, tools), + "Always confirm final findings with raw `git diff`, `read`, or direct file inspection before citing line numbers or writing the review comment.", + "sem may under-cover tests, assets, generated files, or non-semantic glue code. If semantic coverage looks incomplete, fall back to raw git/file inspection.", + ].filter(Boolean); + + if (steps.length === 0) return null; + + const header = target.type === "folder" + ? "## Semantic tooling for this snapshot review" + : "## Semantic tooling for this diff review"; + + return `${header}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join("\n")}`; +} diff --git a/extensions/review/sem-guidance.test.mjs b/extensions/review/sem-guidance.test.mjs new file mode 100644 index 0000000..64b618c --- /dev/null +++ b/extensions/review/sem-guidance.test.mjs @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildSemReviewGuidance, getSemToolAvailability } from "./sem-guidance.mjs"; + +test("getSemToolAvailability detects active sem review tools", () => { + const availability = getSemToolAvailability(["read", "sem_context", "sem_impact"]); + assert.equal(availability.context, true); + assert.equal(availability.impact, true); + assert.equal(availability.diff, false); + assert.equal(availability.any, true); +}); + +test("buildSemReviewGuidance for commit review includes selective sem workflow", () => { + const guidance = buildSemReviewGuidance( + { type: "commit", sha: "abc1234" }, + getSemToolAvailability(["sem_diff", "sem_context", "sem_impact", "sem_entities"]), + ); + + assert.ok(guidance); + assert.match(guidance, /Semantic tooling for this diff review/); + assert.match(guidance, /sem_diff.*abc1234/); + assert.match(guidance, /sem_impact/); + assert.match(guidance, /sem_context/); + assert.match(guidance, /confirm final findings with raw `git diff`, `read`, or direct file inspection/); +}); + +test("buildSemReviewGuidance for folder review avoids diff-first instructions", () => { + const guidance = buildSemReviewGuidance( + { type: "folder", paths: ["src", "docs"] }, + getSemToolAvailability(["sem_context", "sem_entities", "sem_impact"]), + ); + + assert.ok(guidance); + assert.match(guidance, /snapshot review/); + assert.doesNotMatch(guidance, /Start with `sem_diff`/); + assert.match(guidance, /sem_entities/); + assert.match(guidance, /sem_context/); +}); diff --git a/extensions/session-query/session-query.ts b/extensions/session-query/session-query.ts index cc407e1..2e8c4b5 100644 --- a/extensions/session-query/session-query.ts +++ b/extensions/session-query/session-query.ts @@ -4,6 +4,15 @@ * Provides a tool the model can use to query past sessions for context, * decisions, code changes, or other information. * + * Uses an uncapped VCC summary by default (~9K tokens) which preserves ALL + * user messages and assistant reasoning. Falls back to full serialized + * conversation (~80K tokens) via `detailed: true` for queries that need + * exact file contents or tool output. + * + * Standard vccCompile's capBrief(120 lines) loses the first half of longer + * conversations. This extension bypasses that cap by using vcc's building + * blocks (normalize → filterNoise → buildSections) directly. + * * Works with handoff: when a handoff prompt includes "Parent session: ", * the model can use this tool to look up details from that session. */ @@ -19,15 +28,63 @@ import { } from "@mariozechner/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; +import { normalize } from "@sting8k/pi-vcc/src/core/normalize"; +import { filterNoise } from "@sting8k/pi-vcc/src/core/filter-noise"; +import { buildSections } from "@sting8k/pi-vcc/src/core/build-sections"; +import { redact } from "@sting8k/pi-vcc/src/core/redact"; -const QUERY_SYSTEM_PROMPT = `You are a session context assistant. Given the conversation history from a pi coding session and a question, provide a concise answer based on the session contents. +const QUERY_SYSTEM_PROMPT = `You are a session context assistant. Given a session transcript and a question, provide a concise answer based on the session contents. Focus on: -- Specific facts, decisions, and outcomes +- Specific decisions, agreements, and the chosen approach +- What was agreed, what was explicitly rejected, what constraints were set - File paths and code changes mentioned - Key context the user is asking about -Be concise and direct. If the information isn't in the session, say so.`; +Be concise and direct. If the information isn't in the transcript, say so. +If the question asks about exact file contents, specific error messages, or precise tool output, note that these details may not be in the transcript and suggest retrying with detailed=true.`; + +// Cache uncapped summaries per session path. +const summaryCache = new Map(); + +/** + * Build an uncapped VCC summary: uses vcc's normalize/filterNoise/buildSections + * but skips capBrief so the full brief transcript is preserved. + * ~9K tokens vs ~80K for full serialization (8.7x smaller, same accuracy for + * decisions/agreements, only loses tool result contents). + */ +function buildUncappedSummary(llmMessages: Message[]): string { + const blocks = filterNoise(normalize(llmMessages)); + const data = buildSections({ blocks }); + + const section = (title: string, items: string[]): string => { + if (items.length === 0) return ""; + return `[${title}]\n${items.map((i) => `- ${i}`).join("\n")}`; + }; + + const headerParts = [ + section("Session Goal", data.sessionGoal), + section("Files And Changes", data.filesAndChanges), + section("Outstanding Context", data.outstandingContext), + section("User Preferences", data.userPreferences), + ].filter(Boolean); + + const parts: string[] = []; + if (headerParts.length > 0) parts.push(headerParts.join("\n\n")); + if (data.briefTranscript) parts.push(data.briefTranscript); + + if (parts.length === 0) return ""; + return redact(parts.join("\n\n---\n\n")); +} + +function getOrBuildSummary(sessionPath: string, llmMessages: Message[]): string { + const cached = summaryCache.get(sessionPath); + if (cached) return cached; + + const summary = buildUncappedSummary(llmMessages); + if (summary) summaryCache.set(sessionPath, summary); + return summary; +} export default function (pi: ExtensionAPI) { pi.registerTool({ @@ -41,19 +98,16 @@ export default function (pi: ExtensionAPI) { const firstContent = result.content[0]; if (firstContent && firstContent.type === "text") { const text = firstContent.text; - // Parse: **Query:** question\n\n---\n\nanswer const match = text.match(/\*\*Query:\*\* (.+?)\n\n---\n\n([\s\S]+)/); if (match) { const [, query, answer] = match; container.addChild(new Text(theme.bold("Query: ") + theme.fg("accent", query), 0, 0)); container.addChild(new Spacer(1)); - // Render the answer as markdown container.addChild(new Markdown(answer.trim(), 0, 0, getMarkdownTheme(), { color: (text: string) => theme.fg("toolOutput", text), })); } else { - // Fallback for other formats (errors, etc) container.addChild(new Text(theme.fg("toolOutput", text), 0, 0)); } } @@ -67,23 +121,23 @@ export default function (pi: ExtensionAPI) { question: Type.String({ description: "What you want to know about that session (e.g., 'What files were modified?' or 'What approach was chosen?')", }), + detailed: Type.Optional(Type.Boolean({ + description: "Use full serialized conversation instead of uncapped summary. Set true only when you need exact file contents, specific error messages, or precise tool output that isn't in the transcript. Much slower and more expensive (~80K vs ~9K tokens).", + })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { - const { sessionPath, question } = params; + const { sessionPath, question, detailed } = params; - // Helper for error returns const errorResult = (text: string) => ({ content: [{ type: "text" as const, text }], details: { error: true }, }); - // Validate session path if (!sessionPath.endsWith(".jsonl")) { return errorResult(`Error: Invalid session path. Expected a .jsonl file, got: ${sessionPath}`); } - // Check if file exists try { const fs = await import("node:fs"); if (!fs.existsSync(sessionPath)) { @@ -94,16 +148,10 @@ export default function (pi: ExtensionAPI) { } onUpdate?.({ - content: [ - { - type: "text", - text: `Query: ${question}`, - }, - ], - details: { status: "loading", question }, + content: [{ type: "text", text: `Query${detailed ? " (detailed)" : ""}: ${question}` }], + details: { status: "loading", question, detailed: !!detailed }, }); - // Load the session let sessionManager: SessionManager; try { sessionManager = SessionManager.open(sessionPath); @@ -111,7 +159,6 @@ export default function (pi: ExtensionAPI) { return errorResult(`Error loading session: ${err}`); } - // Get conversation from the session const branch = sessionManager.getBranch(); const messages = branch .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") @@ -124,37 +171,42 @@ export default function (pi: ExtensionAPI) { }; } - // Serialize the conversation const llmMessages = convertToLlm(messages); - const conversationText = serializeConversation(llmMessages); - - // Determine the model to use: prefer the queried session's own model, - // fall back to the current session's model. - let queryModel = ctx.model; - const modelChanges = branch.filter( - (entry): entry is SessionEntry & { type: "model_change" } => entry.type === "model_change", - ); - if (modelChanges.length > 0) { - const lastChange = modelChanges[modelChanges.length - 1]!; - const sessionModel = ctx.modelRegistry.find(lastChange.provider, lastChange.modelId); - if (sessionModel) { - queryModel = sessionModel; + + // Default: uncapped vcc summary (~9K tokens, keeps all user+assistant messages) + // Detailed: full serialized conversation (~80K tokens, includes tool results) + let contextText: string; + let contextLabel: string; + if (detailed) { + contextText = serializeConversation(llmMessages); + contextLabel = "Full Session Conversation"; + } else { + contextText = getOrBuildSummary(sessionPath, llmMessages); + contextLabel = "Session Transcript"; + if (!contextText) { + contextText = serializeConversation(llmMessages); + contextLabel = "Full Session Conversation"; } } + // Always use the current session's model — it's available and the user chose it. + const queryModel = ctx.model; if (!queryModel) { return errorResult("Error: No model available to analyze the session."); } try { - const apiKey = await ctx.modelRegistry.getApiKey(queryModel); + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(queryModel); + if (!auth.ok) { + return errorResult(`Error querying session: ${auth.error}`); + } const userMessage: Message = { role: "user", content: [ { type: "text", - text: `## Session Conversation\n\n${conversationText}\n\n## Question\n\n${question}`, + text: `## ${contextLabel}\n\n${contextText}\n\n## Question\n\n${question}`, }, ], timestamp: Date.now(), @@ -163,7 +215,11 @@ export default function (pi: ExtensionAPI) { const response = await complete( queryModel, { systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] }, - { apiKey, signal }, + { + apiKey: auth.apiKey, + headers: auth.headers, + signal, + }, ); if (response.stopReason === "aborted") { @@ -183,6 +239,7 @@ export default function (pi: ExtensionAPI) { details: { sessionPath, question, + detailed: !!detailed, messageCount: messages.length, }, }; diff --git a/extensions/session-switch/index.ts b/extensions/session-switch/index.ts deleted file mode 100644 index 648f3f5..0000000 --- a/extensions/session-switch/index.ts +++ /dev/null @@ -1,452 +0,0 @@ -/** - * Session Switcher — split-panel session picker - * - * Uses Pi's native SessionSelectorComponent on the left (preserving ALL - * controls: search, delete, rename, scope toggle, sort, name filter, etc.) - * with a live conversation preview panel on the right. - * - * Rendered as a centered overlay (like leader-key, but much larger). - */ - -import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, SessionInfo, SessionEntry } from "@mariozechner/pi-coding-agent"; -import { SessionManager, SessionSelectorComponent, parseSessionEntries, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; -import { readFileSync } from "node:fs"; - -import type { Focusable } from "@mariozechner/pi-tui"; -import { Markdown, truncateToWidth, visibleWidth, CURSOR_MARKER } from "@mariozechner/pi-tui"; - -// ───────────────────────────────────────────────────────────────────────────── -// Preview helpers -// ───────────────────────────────────────────────────────────────────────────── - -/** A message block from the session */ -interface MessageBlock { - role: "user" | "assistant"; - text: string; -} - -const previewCache = new Map(); - -function extractText(message: any): string { - const content = message.content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter((b: any) => b.type === "text") - .map((b: any) => b.text) - .join("\n"); - } - return ""; -} - -function buildMessageBlocks(sessionPath: string): MessageBlock[] { - try { - const raw = readFileSync(sessionPath, "utf-8"); - const entries = parseSessionEntries(raw); - const blocks: MessageBlock[] = []; - - for (const entry of entries) { - if ((entry as any).type !== "message") continue; - const msg = (entry as SessionEntry & { type: "message" }).message as any; - if (!msg || (msg.role !== "user" && msg.role !== "assistant")) continue; - - const text = extractText(msg); - if (!text.trim()) continue; - - blocks.push({ role: msg.role as "user" | "assistant", text: text.trim() }); - } - - // Keep last N messages - const maxBlocks = 50; - return blocks.length > maxBlocks ? blocks.slice(blocks.length - maxBlocks) : blocks; - } catch { - return []; - } -} - -function getMessageBlocks(path: string): MessageBlock[] { - const cached = previewCache.get(path); - if (cached) return cached; - - const blocks = buildMessageBlocks(path); - previewCache.set(path, blocks); - return blocks; -} - -function relativeTime(date: Date): string { - const diff = Date.now() - date.getTime(); - const minutes = Math.floor(diff / 60_000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - if (days > 30) return `${Math.floor(days / 30)}mo`; - if (days > 0) return `${days}d`; - if (hours > 0) return `${hours}h`; - if (minutes > 0) return `${minutes}m`; - return "now"; -} - -/** Pad or truncate a (possibly ANSI-styled) string to exact visible width */ -function padTo(s: string, w: number): string { - const vis = visibleWidth(s); - if (vis >= w) return truncateToWidth(s, w); - return s + " ".repeat(w - vis); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Border-stripping helper -// ───────────────────────────────────────────────────────────────────────────── - -/** Strip ANSI escapes to get visible text */ -const ANSI_RE = /\x1b\[[0-9;]*m|\x1b_[^\x07]*\x07/g; - -/** Detect DynamicBorder lines (just ─ repeated, possibly with ANSI) */ -function isBorderLine(line: string): boolean { - const stripped = line.replace(ANSI_RE, "").trim(); - return stripped.length > 0 && /^─+$/.test(stripped); -} - -/** - * Remove the top/bottom DynamicBorder lines (and their adjacent spacers) - * that SessionSelectorComponent.buildBaseLayout() adds internally. - */ -function stripSelectorBorders(lines: string[]): string[] { - // Strip border lines - let result = lines.filter((l) => !isBorderLine(l)); - // Trim leading empty lines (spacers around removed border) - while (result.length > 0 && result[0].trim() === "") result.shift(); - // Trim trailing empty lines - while (result.length > 0 && result[result.length - 1].trim() === "") result.pop(); - return result; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Split overlay component -// ───────────────────────────────────────────────────────────────────────────── - -class SplitOverlay implements Focusable { - private selector: SessionSelectorComponent; - private sessionByPath: Map; - private getTermRows: () => number; - private requestRender: () => void; - private theme: any; - private mdTheme: any; - - // Track selection to reset preview position - private lastSelectedPath: string | undefined; - - // ── Focusable — delegate to selector for IME cursor support ── - _focused = false; - get focused(): boolean { - return this._focused; - } - set focused(v: boolean) { - this._focused = v; - this.selector.focused = v; - } - - constructor(opts: { - selector: SessionSelectorComponent; - sessionByPath: Map; - getTermRows: () => number; - requestRender: () => void; - theme: any; - mdTheme: any; - }) { - this.selector = opts.selector; - this.sessionByPath = opts.sessionByPath; - this.getTermRows = opts.getTermRows; - this.requestRender = opts.requestRender; - this.theme = opts.theme; - this.mdTheme = opts.mdTheme; - } - - handleInput(data: string): void { - // All input goes to the native selector (search, nav, delete, rename, etc.) - this.selector.handleInput(data); - this.requestRender(); - } - - render(width: number): string[] { - const th = this.theme; - const termRows = this.getTermRows(); - - // ── Outer frame dimensions ── - const innerW = width - 2; // inside left/right border │ - const leftW = Math.max(25, Math.floor(innerW * 0.40)); - const rightW = innerW - leftW - 1; // -1 for center │ - - // ── Render selector (strip internal DynamicBorder lines) ── - const selectorLines = stripSelectorBorders(this.selector.render(leftW)); - - // ── Determine total height ── - const targetH = Math.max(selectorLines.length, Math.floor(termRows * 0.80)); - - // ── Detect selection changes ── - const selectedPath = this.selector.getSessionList().getSelectedSessionPath(); - if (selectedPath !== this.lastSelectedPath) { - this.lastSelectedPath = selectedPath; - } - - // ── Build right panel ── - const rightLines = this.buildPreview(selectedPath, rightW, targetH); - - // ── Pad left panel to target height ── - while (selectorLines.length < targetH) selectorLines.push(""); - - // ── Assemble framed output ── - const lines: string[] = []; - - // Top border - lines.push( - th.fg("border", "╭" + "─".repeat(leftW) + "┬" + "─".repeat(rightW) + "╮"), - ); - - // Content rows - const sep = th.fg("border", "│"); - for (let i = 0; i < targetH; i++) { - lines.push( - sep + - padTo(selectorLines[i] ?? "", leftW) + - sep + - padTo(rightLines[i] ?? "", rightW) + - sep, - ); - } - - // Bottom border - lines.push( - th.fg("border", "╰" + "─".repeat(leftW) + "┴" + "─".repeat(rightW) + "╯"), - ); - - return lines; - } - - // ── Right panel: session info + conversation preview ───────────────── - - private buildPreview(selectedPath: string | undefined, w: number, h: number): string[] { - const th = this.theme; - - if (!selectedPath) { - return this.centeredMessage(th.fg("dim", "(no session selected)"), w, h); - } - - const session = this.sessionByPath.get(selectedPath); - const lines: string[] = []; - - // ── Header: session info ── - if (session) { - const name = session.name || session.firstMessage.split("\n")[0]?.trim() || "(unnamed)"; - const msgs = `${session.messageCount} msg${session.messageCount !== 1 ? "s" : ""}`; - const time = relativeTime(session.modified); - const cwd = session.cwd || ""; - - lines.push(truncateToWidth( - " " + th.fg("accent", th.bold(name)), - w, - )); - lines.push(truncateToWidth( - " " + th.fg("dim", `${msgs} • ${time} • ${cwd}`), - w, - )); - lines.push(th.fg("border", " " + "─".repeat(Math.max(0, w - 2)))); - } - - // ── Preview content ── - const blocks = getMessageBlocks(selectedPath); - const headerH = lines.length; - const contentH = h - headerH; - - if (blocks.length === 0) { - const emptyLines = this.centeredMessage(th.fg("dim", "(no preview)"), w, contentH); - lines.push(...emptyLines); - return lines; - } - - // Render all message blocks using Markdown components - const allContentLines: string[] = []; - let lastRole: string | undefined; - - for (const block of blocks) { - // Spacer between messages - if (allContentLines.length > 0) { - allContentLines.push(""); - } - - if (block.role === "user") { - // ── USER pill ── (accent color inverted for a bold colored background) - const pill = th.bold(th.inverse(th.fg("accent", " USER "))); - allContentLines.push(" " + pill); - - // User: full-width Markdown with userMessage bg/fg, extra vertical padding - const bgBlank = th.bg("userMessageBg", " ".repeat(w)); - allContentLines.push(bgBlank); // top padding - - const md = new Markdown(block.text, 1, 0, this.mdTheme, { - bgColor: (text: string) => th.bg("userMessageBg", text), - color: (text: string) => th.fg("userMessageText", text), - }); - const rendered = md.render(w); - // Apply bg to the full width of each line - for (const line of rendered) { - const padded = padTo(line, w); - allContentLines.push(th.bg("userMessageBg", padded)); - } - - allContentLines.push(bgBlank); // bottom padding - } else { - // Only show AGENT pill on first consecutive agent message - if (lastRole !== "assistant") { - const pill = th.bold(th.inverse(th.fg("success", " AGENT "))); - allContentLines.push(" " + pill); - } - - // Assistant: render with Markdown, no special background - const md = new Markdown(block.text, 1, 0, this.mdTheme); - const rendered = md.render(w); - allContentLines.push(...rendered); - } - - lastRole = block.role; - } - - // Show from the top (beginning of conversation) - for (let i = 0; i < contentH; i++) { - if (i < allContentLines.length) { - lines.push(truncateToWidth(allContentLines[i], w)); - } else { - lines.push(""); - } - } - - return lines; - } - - private centeredMessage(msg: string, w: number, h: number): string[] { - const mid = Math.floor(h / 2); - const vis = visibleWidth(msg); - const padLeft = Math.max(0, Math.floor((w - vis) / 2)); - - return Array.from({ length: h }, (_, i) => - i === mid ? " ".repeat(padLeft) + msg : "", - ); - } - - invalidate(): void { - this.selector.invalidate(); - } - - dispose(): void { - previewCache.clear(); - // SessionSelectorComponent extends Container which has no dispose(), - // so use optional chaining to be safe. - (this.selector as any).dispose?.(); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Exported runner (callable from leader-key or other extensions) -// ───────────────────────────────────────────────────────────────────────────── - -export async function runSessionSwitch(pi: ExtensionAPI, ctx: ExtensionContext): Promise { - if (!ctx.hasUI) return; - - // Set editor text to the command and simulate Enter to execute it - ctx.ui.setEditorText("/switch-session"); - // Inject Enter keypress into stdin so the command auto-submits - setTimeout(() => process.stdin.emit("data", "\r"), 0); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Extension -// ───────────────────────────────────────────────────────────────────────────── - -export default function (pi: ExtensionAPI) { - pi.registerCommand("switch-session", { - description: "Session picker with conversation preview (split panel)", - handler: async (_args, ctx: ExtensionCommandContext) => { - if (!ctx.hasUI) return; - - previewCache.clear(); - - const currentCwd = ctx.cwd; - const currentSessionFilePath = ctx.sessionManager.getSessionFile(); - - const sessionByPath = new Map(); - const recordSessions = (sessions: SessionInfo[]) => { - for (const session of sessions) { - sessionByPath.set(session.path, session); - } - }; - - const currentSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => { - const sessions = await SessionManager.list(currentCwd, undefined, onProgress); - recordSessions(sessions); - return sessions; - }; - - const allSessionsLoader = async (onProgress?: (loaded: number, total: number) => void) => { - const sessions = await SessionManager.listAll(onProgress); - recordSessions(sessions); - return sessions; - }; - - const selectedPath = await ctx.ui.custom( - (tui, theme, _kb, done) => { - const selector = new SessionSelectorComponent( - currentSessionsLoader, - allSessionsLoader, - (path) => done(path), - () => done(null), - () => done(null), - () => tui.requestRender(), - { - showRenameHint: true, - renameSession: async (sessionPath: string, newName: string | undefined) => { - const name = (newName ?? "").trim(); - if (!name) return; - - if (currentSessionFilePath && sessionPath === currentSessionFilePath) { - pi.setSessionName(name); - return; - } - - const mgr = SessionManager.open(sessionPath); - mgr.appendSessionInfo(name); - }, - }, - currentSessionFilePath, - ); - - // Show more sessions in the list (default is 10) - const termRows = tui.terminal?.rows ?? 40; - (selector.getSessionList() as any).maxVisible = Math.max(15, Math.floor(termRows * 0.6)); - - return new SplitOverlay({ - selector, - sessionByPath, - getTermRows: () => tui.terminal?.rows ?? 40, - requestRender: () => tui.requestRender(), - theme, - mdTheme: getMarkdownTheme(), - }); - }, - { - overlay: true, - overlayOptions: { - anchor: "center", - width: "90%", - minWidth: 80, - maxHeight: "85%", - }, - }, - ); - - if (!selectedPath) return; - - const result = await ctx.switchSession(selectedPath); - if (result.cancelled) { - ctx.ui.notify("Session switch cancelled", "info"); - } - }, - }); -} diff --git a/extensions/shared/thinking-colors.ts b/extensions/shared/thinking-colors.ts new file mode 100644 index 0000000..a7f6d35 --- /dev/null +++ b/extensions/shared/thinking-colors.ts @@ -0,0 +1,9 @@ +/** Theme roles for thinking-level indicators. Shared across pickers and footer. */ +export const THINKING_ROLES: Record = { + off: "dim", + minimal: "dim", + low: "success", + medium: "warning", + high: "bashMode", + xhigh: "error", +}; diff --git a/extensions/tool-pills/diff-renderer.ts b/extensions/tool-pills/diff-renderer.ts new file mode 100644 index 0000000..3405c27 --- /dev/null +++ b/extensions/tool-pills/diff-renderer.ts @@ -0,0 +1,1543 @@ +/** + * Diff renderer — Shiki-powered terminal diff for pi write/edit tools. + * + * Based on pi-diff by @heyhuynhgiabuu (https://github.com/buddingnewinsights/pi-diff) + * + * Architecture: + * 1. Syntax-highlight full code blocks via Shiki → ANSI (fg-only codes) + * 2. Layer diff background colors underneath (composites at cell level) + * 3. For word-level changes, inject brighter bg at changed char positions + * 4. Result: syntax fg + diff bg + word emphasis — all three visible together + */ + +import { existsSync, readFileSync } from "node:fs"; +import { extname, relative } from "node:path"; + +import { codeToANSI } from "@shikijs/cli"; +import * as Diff from "diff"; +import type { BundledLanguage, BundledTheme } from "shiki"; + +// --------------------------------------------------------------------------- +// Diff Theme System +// --------------------------------------------------------------------------- + +interface DiffPreset { + name: string; + description: string; + shikiTheme?: string; + bgAdd?: string; + bgDel?: string; + bgAddHighlight?: string; + bgDelHighlight?: string; + bgGutterAdd?: string; + bgGutterDel?: string; + bgEmpty?: string; + fgAdd?: string; + fgDel?: string; + fgDim?: string; + fgLnum?: string; + fgRule?: string; + fgStripe?: string; + fgSafeMuted?: string; +} + +interface DiffUserConfig { + diffTheme?: string; + diffColors?: Record; +} + +const DIFF_PRESETS: Record = { + default: { + name: "default", + description: "Original pi-diff colors — tuned for dark theme bases (~#1e1e2e)", + bgAdd: "#162620", + bgDel: "#2d1919", + bgAddHighlight: "#234b32", + bgDelHighlight: "#502323", + bgGutterAdd: "#12201a", + bgGutterDel: "#261616", + bgEmpty: "#121212", + fgDim: "#505050", + fgLnum: "#646464", + fgRule: "#323232", + fgStripe: "#282828", + fgSafeMuted: "#8b949e", + }, + midnight: { + name: "midnight", + description: "Subtle tints for pure black (#000000) terminal backgrounds", + bgAdd: "#0d1a12", + bgDel: "#1a0d0d", + bgAddHighlight: "#1a3825", + bgDelHighlight: "#381a1a", + bgGutterAdd: "#091208", + bgGutterDel: "#120908", + bgEmpty: "#080808", + fgDim: "#404040", + fgLnum: "#505050", + fgRule: "#282828", + fgStripe: "#1e1e1e", + fgSafeMuted: "#8b949e", + }, + subtle: { + name: "subtle", + description: "Minimal backgrounds — barely-there tints for a clean look", + bgAdd: "#081008", + bgDel: "#100808", + bgAddHighlight: "#122818", + bgDelHighlight: "#281212", + bgGutterAdd: "#060c06", + bgGutterDel: "#0c0606", + bgEmpty: "#060606", + fgDim: "#383838", + fgLnum: "#484848", + fgRule: "#242424", + fgStripe: "#181818", + fgSafeMuted: "#8b949e", + }, + neon: { + name: "neon", + description: "Higher contrast backgrounds for better visibility", + bgAdd: "#1a3320", + bgDel: "#331a16", + bgAddHighlight: "#2d5c3a", + bgDelHighlight: "#5c2d2d", + bgGutterAdd: "#142818", + bgGutterDel: "#28120e", + bgEmpty: "#141414", + fgDim: "#606060", + fgLnum: "#787878", + fgRule: "#404040", + fgStripe: "#303030", + fgSafeMuted: "#9da5ae", + }, +}; + +function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null { + const esc = "\u001b"; + const m = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`)); + return m ? { r: +m[1], g: +m[2], b: +m[3] } : null; +} + +function hexToBgAnsi(hex: string): string { + if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return ""; + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `\x1b[48;2;${r};${g};${b}m`; +} + +function hexToFgAnsi(hex: string): string { + if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return ""; + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `\x1b[38;2;${r};${g};${b}m`; +} + +function deriveBgFromFg(fgAnsi: string, intensity: number): string { + const rgb = parseAnsiRgb(fgAnsi); + if (!rgb) return ""; + const r = Math.round(rgb.r * intensity); + const g = Math.round(rgb.g * intensity); + const b = Math.round(rgb.b * intensity); + return `\x1b[48;2;${r};${g};${b}m`; +} + +function mixBg( + base: { r: number; g: number; b: number }, + accent: { r: number; g: number; b: number }, + intensity: number, +): string { + const r = Math.round(base.r + (accent.r - base.r) * intensity); + const g = Math.round(base.g + (accent.g - base.g) * intensity); + const b = Math.round(base.b + (accent.b - base.b) * intensity); + return `\x1b[48;2;${r};${g};${b}m`; +} + +let _autoDerivePending = true; +let _hasExplicitBgConfig = false; + +function autoDeriveBgFromTheme(theme: any): void { + if (!theme?.getFgAnsi) return; + try { + const fgAdd = theme.getFgAnsi("toolDiffAdded"); + const fgDel = theme.getFgAnsi("toolDiffRemoved"); + const addRgb = parseAnsiRgb(fgAdd); + const delRgb = parseAnsiRgb(fgDel); + if (!addRgb || !delRgb) return; + + let base = { r: 0, g: 0, b: 0 }; + if (theme.getBgAnsi) { + try { + const bgAnsi = theme.getBgAnsi("toolSuccessBg"); + const parsed = parseAnsiRgb(bgAnsi); + if (parsed) { + base = parsed; + BG_BASE = bgAnsi; + } + } catch { + /* no toolSuccessBg — use black */ + } + } + + BG_ADD = mixBg(base, addRgb, 0.08); + BG_DEL = mixBg(base, delRgb, 0.1); + BG_ADD_W = mixBg(base, addRgb, 0.2); + BG_DEL_W = mixBg(base, delRgb, 0.22); + BG_GUTTER_ADD = mixBg(base, addRgb, 0.05); + BG_GUTTER_DEL = mixBg(base, delRgb, 0.06); + BG_EMPTY = BG_BASE; + RST = `\x1b[0m${BG_BASE}`; + DIVIDER = `${FG_RULE}│${RST}`; + } catch { + // Fall back to defaults silently + } +} + +function loadDiffConfig(): DiffUserConfig { + const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`]; + for (const p of paths) { + try { + if (existsSync(p)) { + const raw = JSON.parse(readFileSync(p, "utf-8")); + if (raw.diffTheme || raw.diffColors) { + return { diffTheme: raw.diffTheme, diffColors: raw.diffColors }; + } + } + } catch { + // skip invalid files + } + } + return {}; +} + +function applyDiffPalette(): void { + const config = loadDiffConfig(); + const preset = config.diffTheme ? DIFF_PRESETS[config.diffTheme] : null; + if (preset) _hasExplicitBgConfig = true; + + const ov = config.diffColors ?? {}; + if (Object.keys(ov).length > 0) _hasExplicitBgConfig = true; + + const applyBg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => { + if (envName && process.env[envName]) return; + const hex = ov[key] ?? presetVal; + if (hex) { + const a = hexToBgAnsi(hex); + if (a) set(a); + } + }; + const applyFg = (envName: string | null, key: string, presetVal: string | undefined, set: (v: string) => void) => { + if (envName && process.env[envName]) return; + const hex = ov[key] ?? presetVal; + if (hex) { + const a = hexToFgAnsi(hex); + if (a) set(a); + } + }; + + applyBg("DIFF_BG_ADD", "bgAdd", preset?.bgAdd, (v) => { BG_ADD = v; }); + applyBg("DIFF_BG_DEL", "bgDel", preset?.bgDel, (v) => { BG_DEL = v; }); + applyBg("DIFF_BG_ADD_HL", "bgAddHighlight", preset?.bgAddHighlight, (v) => { BG_ADD_W = v; }); + applyBg("DIFF_BG_DEL_HL", "bgDelHighlight", preset?.bgDelHighlight, (v) => { BG_DEL_W = v; }); + applyBg("DIFF_BG_GUTTER_ADD", "bgGutterAdd", preset?.bgGutterAdd, (v) => { BG_GUTTER_ADD = v; }); + applyBg("DIFF_BG_GUTTER_DEL", "bgGutterDel", preset?.bgGutterDel, (v) => { BG_GUTTER_DEL = v; }); + applyBg(null, "bgEmpty", preset?.bgEmpty, (v) => { BG_EMPTY = v; }); + + applyFg("DIFF_FG_ADD", "fgAdd", preset?.fgAdd, (v) => { FG_ADD = v; }); + applyFg("DIFF_FG_DEL", "fgDel", preset?.fgDel, (v) => { FG_DEL = v; }); + applyFg(null, "fgDim", preset?.fgDim, (v) => { FG_DIM = v; }); + applyFg(null, "fgLnum", preset?.fgLnum, (v) => { FG_LNUM = v; }); + applyFg(null, "fgRule", preset?.fgRule, (v) => { FG_RULE = v; }); + applyFg(null, "fgStripe", preset?.fgStripe, (v) => { FG_STRIPE = v; }); + applyFg(null, "fgSafeMuted", preset?.fgSafeMuted, (v) => { FG_SAFE_MUTED = v; }); + + const shiki = ov.shikiTheme ?? preset?.shikiTheme; + if (shiki) THEME = shiki as BundledTheme; + + DIVIDER = `${FG_RULE}│${RST}`; + DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM }; + _autoDerivePending = !_hasExplicitBgConfig; +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +let THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark"; + +function envInt(name: string, fallback: number): number { + const v = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(v) && v > 0 ? v : fallback; +} + +function envFg(name: string, fallback: string): string { + const hex = process.env[name]; + if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback; + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `\x1b[38;2;${r};${g};${b}m`; +} + +function envBg(name: string, fallback: string): string { + const hex = process.env[name]; + if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return fallback; + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `\x1b[48;2;${r};${g};${b}m`; +} + +const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", 150); +const SPLIT_MIN_CODE_WIDTH = envInt("DIFF_SPLIT_MIN_CODE_WIDTH", 60); +const SPLIT_MAX_WRAP_RATIO = 0.2; +const SPLIT_MAX_WRAP_LINES = 8; + +const MAX_TERM_WIDTH = 210; +const DEFAULT_TERM_WIDTH = 200; + +const MAX_PREVIEW_LINES = 60; +const MAX_RENDER_LINES = 150; +const MAX_HL_CHARS = 80_000; +const CACHE_LIMIT = 192; + +const WORD_DIFF_MIN_SIM = 0.15; + +const MAX_WRAP_ROWS_WIDE = 3; +const MAX_WRAP_ROWS_MED = 2; +const MAX_WRAP_ROWS_NARROW = 1; + +// --------------------------------------------------------------------------- +// ANSI +// --------------------------------------------------------------------------- + +let RST = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; + +let BG_ADD = envBg("DIFF_BG_ADD", "\x1b[48;2;22;38;32m"); +let BG_DEL = envBg("DIFF_BG_DEL", "\x1b[48;2;45;25;25m"); +let BG_ADD_W = envBg("DIFF_BG_ADD_HL", "\x1b[48;2;35;75;50m"); +let BG_DEL_W = envBg("DIFF_BG_DEL_HL", "\x1b[48;2;80;35;35m"); +let BG_GUTTER_ADD = envBg("DIFF_BG_GUTTER_ADD", "\x1b[48;2;18;32;26m"); +let BG_GUTTER_DEL = envBg("DIFF_BG_GUTTER_DEL", "\x1b[48;2;38;22;22m"); +const BG_GUTTER_CTX = ""; +let BG_EMPTY = "\x1b[48;2;18;18;18m"; + +let FG_ADD = envFg("DIFF_FG_ADD", "\x1b[38;2;100;180;120m"); +let FG_DEL = envFg("DIFF_FG_DEL", "\x1b[38;2;200;100;100m"); +let FG_DIM = "\x1b[38;2;80;80;80m"; +let FG_LNUM = "\x1b[38;2;100;100;100m"; +let FG_RULE = "\x1b[38;2;50;50;50m"; +let FG_SAFE_MUTED = "\x1b[38;2;139;148;158m"; + +let FG_STRIPE = "\x1b[38;2;40;40;40m"; + +const BORDER_BAR = "▌"; + +function stripes(w: number, _rowOffset: number): string { + return BG_BASE + FG_STRIPE + "╱".repeat(w) + RST; +} + +let DIVIDER = `${FG_RULE}│${RST}`; +const ESC_RE = "\u001b"; +const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g"); +const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g"); +const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g"); +const BG_DEFAULT = "\x1b[49m"; +let BG_BASE = BG_DEFAULT; + +// --------------------------------------------------------------------------- +// Theme-aware diff colors +// --------------------------------------------------------------------------- + +interface DiffColors { + fgAdd: string; + fgDel: string; + fgCtx: string; +} + +let DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM }; + +function resolveDiffColors(theme?: any): DiffColors { + if (theme?.getBgAnsi && BG_BASE === BG_DEFAULT) { + try { + const bgAnsi = theme.getBgAnsi("toolSuccessBg"); + const parsed = parseAnsiRgb(bgAnsi); + if (parsed) { + BG_BASE = bgAnsi; + RST = `\x1b[0m${BG_BASE}`; + } + } catch { + /* ignore */ + } + } + + if (_autoDerivePending && theme?.getFgAnsi) { + autoDeriveBgFromTheme(theme); + _autoDerivePending = false; + } + + if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS; + try { + const fgAdd = theme.getFgAnsi("toolDiffAdded") || FG_ADD; + const fgDel = theme.getFgAnsi("toolDiffRemoved") || FG_DEL; + const fgCtx = theme.getFgAnsi("toolDiffContext") || FG_DIM; + return { fgAdd, fgDel, fgCtx }; + } catch { + return DEFAULT_DIFF_COLORS; + } +} + +// --------------------------------------------------------------------------- +// Adaptive helpers +// --------------------------------------------------------------------------- + +function adaptiveWrapRows(tw?: number): number { + const w = tw ?? termW(); + if (w >= 180) return MAX_WRAP_ROWS_WIDE; + if (w >= 120) return MAX_WRAP_ROWS_MED; + return MAX_WRAP_ROWS_NARROW; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DiffLine { + type: "add" | "del" | "ctx" | "sep"; + oldNum: number | null; + newNum: number | null; + content: string; +} + +interface ParsedDiff { + lines: DiffLine[]; + added: number; + removed: number; + chars: number; +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function strip(s: string): string { + return s.replace(ANSI_RE, ""); +} + +function tabs(s: string): string { + return s.replace(/\t/g, " "); +} + +function termW(): number { + const raw = + process.stdout.columns || + (process.stderr as any).columns || + Number.parseInt(process.env.COLUMNS ?? "", 10) || + DEFAULT_TERM_WIDTH; + return Math.max(80, Math.min(raw - 4, MAX_TERM_WIDTH)); +} + +function fit(s: string, w: number): string { + if (w <= 0) return ""; + const plain = strip(s); + if (plain.length <= w) return s + " ".repeat(w - plain.length); + const showW = w > 2 ? w - 1 : w; + let vis = 0, + i = 0; + while (i < s.length && vis < showW) { + if (s[i] === "\x1b") { + const e = s.indexOf("m", i); + if (e !== -1) { + i = e + 1; + continue; + } + } + vis++; + i++; + } + return w > 2 ? `${s.slice(0, i)}${RST}${FG_DIM}›${RST}` : `${s.slice(0, i)}${RST}`; +} + +function ansiState(s: string): string { + let fg = "", + bg = ""; + for (const match of s.matchAll(ANSI_CAPTURE_RE)) { + const p = match[1] ?? ""; + const seq = match[0] ?? ""; + if (p === "0") { + fg = ""; + bg = ""; + } else if (p === "39") { + fg = ""; + } else if (p.startsWith("38;")) { + fg = seq; + } else if (p.startsWith("48;")) { + bg = seq; + } + } + return bg + fg; +} + +function isLowContrastShikiFg(params: string): boolean { + if (params === "30" || params === "90") return true; + if (params === "38;5;0" || params === "38;5;8") return true; + if (!params.startsWith("38;2;")) return false; + const parts = params.split(";").map(Number); + if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false; + const [, , r, g, b] = parts; + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luminance < 72; +} + +function normalizeShikiContrast(ansi: string): string { + return ansi.replace(ANSI_PARAM_CAPTURE_RE, (seq, params: string) => + isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq, + ); +} + +function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = ""): string[] { + if (w <= 0) return [""]; + const plain = strip(s); + if (plain.length <= w) { + const pad = w - plain.length; + return pad > 0 ? [s + fillBg + " ".repeat(pad) + (fillBg ? RST : "")] : [s]; + } + + const rows: string[] = []; + let row = "", + vis = 0, + i = 0; + let onLastRow = false; + let effW = w; + + while (i < s.length) { + if (!onLastRow && rows.length >= maxRows - 1) { + onLastRow = true; + effW = w > 2 ? w - 1 : w; + } + + if (s[i] === "\x1b") { + const end = s.indexOf("m", i); + if (end !== -1) { + row += s.slice(i, end + 1); + i = end + 1; + continue; + } + } + + if (vis >= effW) { + if (onLastRow) { + let hasMore = false; + for (let j = i; j < s.length; j++) { + if (s[j] === "\x1b") { + const e2 = s.indexOf("m", j); + if (e2 !== -1) { + j = e2; + continue; + } + } + hasMore = true; + break; + } + if (hasMore && w > 2) row += `${RST}${FG_DIM}›${RST}`; + else row += fillBg + " ".repeat(Math.max(0, w - vis)) + RST; + rows.push(row); + return rows; + } + const state = ansiState(row); + rows.push(row + RST); + row = state + fillBg; + vis = 0; + if (rows.length >= maxRows - 1) { + onLastRow = true; + effW = w > 2 ? w - 1 : w; + } + } + + row += s[i]; + vis++; + i++; + } + + if (row.length > 0 || rows.length === 0) { + rows.push(row + fillBg + " ".repeat(Math.max(0, w - vis)) + RST); + } + return rows; +} + +function lnum(n: number | null, w: number, fg = FG_LNUM): string { + if (n === null) return " ".repeat(w); + const v = String(n); + return `${fg}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`; +} + +function shortPath(cwd: string, home: string, p: string): string { + if (!p) return ""; + const r = relative(cwd, p); + if (!r.startsWith("..") && !r.startsWith("/")) return r; + return p.replace(home, "~"); +} + +function summarize(a: number, d: number): string { + const p: string[] = []; + if (a > 0) p.push(`${FG_ADD}+${a}${RST}`); + if (d > 0) p.push(`${FG_DEL}-${d}${RST}`); + return p.length ? p.join(" ") : `${FG_DIM}no changes${RST}`; +} + +function rule(w: number): string { + return `${BG_BASE}${FG_RULE}${"─".repeat(w)}${RST}`; +} + +function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean { + if (!diff.lines.length) return false; + if (tw < SPLIT_MIN_WIDTH) return false; + + const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length); + const half = Math.floor((tw - 1) / 2); + const gw = nw + 5; + const cw = Math.max(12, half - gw); + if (cw < SPLIT_MIN_CODE_WIDTH) return false; + + const vis = diff.lines.slice(0, maxRows); + let contentLines = 0; + let wrapCandidates = 0; + for (const l of vis) { + if (l.type === "sep") continue; + contentLines++; + if (tabs(l.content).length > cw) wrapCandidates++; + } + if (contentLines === 0) return true; + + const wrapRatio = wrapCandidates / contentLines; + if (wrapCandidates >= SPLIT_MAX_WRAP_LINES) return false; + if (wrapRatio >= SPLIT_MAX_WRAP_RATIO) return false; + return true; +} + +// --------------------------------------------------------------------------- +// Language detection +// --------------------------------------------------------------------------- + +const EXT_LANG: Record = { + ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", + mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby", + rs: "rust", go: "go", java: "java", c: "c", cpp: "cpp", + h: "c", hpp: "cpp", cs: "csharp", swift: "swift", kt: "kotlin", + html: "html", css: "css", scss: "scss", json: "json", + yaml: "yaml", yml: "yaml", toml: "toml", md: "markdown", + sql: "sql", sh: "bash", bash: "bash", zsh: "bash", + lua: "lua", php: "php", dart: "dart", xml: "xml", + graphql: "graphql", svelte: "svelte", vue: "vue", +}; + +function lang(fp: string): BundledLanguage | undefined { + return EXT_LANG[extname(fp).slice(1).toLowerCase()]; +} + +// --------------------------------------------------------------------------- +// Shiki ANSI cache + pre-warm +// --------------------------------------------------------------------------- + +codeToANSI("", "typescript", THEME).catch(() => {}); + +const _cache = new Map(); + +function _touch(k: string, v: string[]): string[] { + _cache.delete(k); + _cache.set(k, v); + while (_cache.size > CACHE_LIMIT) { + const first = _cache.keys().next().value; + if (first === undefined) break; + _cache.delete(first); + } + return v; +} + +async function hlBlock(code: string, language: BundledLanguage | undefined): Promise { + if (!code) return [""]; + if (!language || code.length > MAX_HL_CHARS) return code.split("\n"); + + const k = `${THEME}\0${language}\0${code}`; + const hit = _cache.get(k); + if (hit) return _touch(k, hit); + + try { + const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME)); + const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n"); + return _touch(k, out); + } catch { + return code.split("\n"); + } +} + +// --------------------------------------------------------------------------- +// Diff parsing +// --------------------------------------------------------------------------- + +function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff { + const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctx }); + const lines: DiffLine[] = []; + let added = 0, + removed = 0; + + for (let hi = 0; hi < patch.hunks.length; hi++) { + if (hi > 0) { + const prev = patch.hunks[hi - 1]; + const gap = patch.hunks[hi].oldStart - (prev.oldStart + prev.oldLines); + lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" }); + } + const h = patch.hunks[hi]; + let oL = h.oldStart, + nL = h.newStart; + for (const raw of h.lines) { + if (raw === "\\ No newline at end of file") continue; + const ch = raw[0], + text = raw.slice(1); + if (ch === "+") { + lines.push({ type: "add", oldNum: null, newNum: nL++, content: text }); + added++; + } else if (ch === "-") { + lines.push({ type: "del", oldNum: oL++, newNum: null, content: text }); + removed++; + } else { + lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text }); + } + } + } + return { lines, added, removed, chars: oldContent.length + newContent.length }; +} + +// --------------------------------------------------------------------------- +// Word diff + bg injection +// --------------------------------------------------------------------------- + +function wordDiffAnalysis( + a: string, + b: string, +): { + similarity: number; + oldRanges: Array<[number, number]>; + newRanges: Array<[number, number]>; +} { + if (!a && !b) return { similarity: 1, oldRanges: [], newRanges: [] }; + const parts = Diff.diffWords(a, b); + const oldRanges: Array<[number, number]> = []; + const newRanges: Array<[number, number]> = []; + let oPos = 0, + nPos = 0, + same = 0; + for (const p of parts) { + if (p.removed) { + oldRanges.push([oPos, oPos + p.value.length]); + oPos += p.value.length; + } else if (p.added) { + newRanges.push([nPos, nPos + p.value.length]); + nPos += p.value.length; + } else { + const len = p.value.length; + same += len; + oPos += len; + nPos += len; + } + } + const maxLen = Math.max(a.length, b.length); + return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges }; +} + +function injectBg(ansiLine: string, ranges: Array<[number, number]>, baseBg: string, hlBg: string): string { + if (!ranges.length) return baseBg + ansiLine + RST; + + let out = baseBg; + let vis = 0; + let inHL = false; + let ri = 0; + let i = 0; + + while (i < ansiLine.length) { + if (ansiLine[i] === "\x1b") { + const m = ansiLine.indexOf("m", i); + if (m !== -1) { + const seq = ansiLine.slice(i, m + 1); + out += seq; + if (seq === "\x1b[0m") out += inHL ? hlBg : baseBg; + i = m + 1; + continue; + } + } + while (ri < ranges.length && vis >= ranges[ri][1]) ri++; + const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1]; + if (want !== inHL) { + inHL = want; + out += inHL ? hlBg : baseBg; + } + out += ansiLine[i]; + vis++; + i++; + } + return out + RST; +} + +function plainWordDiff(oldText: string, newText: string): { old: string; new: string } { + const parts = Diff.diffWords(oldText, newText); + let o = "", + n = ""; + for (const p of parts) { + if (p.removed) o += `${BG_DEL_W}${p.value}${RST}${BG_DEL}`; + else if (p.added) n += `${BG_ADD_W}${p.value}${RST}${BG_ADD}`; + else { + o += p.value; + n += p.value; + } + } + return { old: o, new: n }; +} + +// --------------------------------------------------------------------------- +// Unified view +// --------------------------------------------------------------------------- + +async function renderUnified( + diff: ParsedDiff, + language: BundledLanguage | undefined, + max = MAX_RENDER_LINES, + dc: DiffColors = DEFAULT_DIFF_COLORS, +): Promise { + if (!diff.lines.length) return ""; + + const vis = diff.lines.slice(0, max); + const tw = termW(); + const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length); + const gw = nw + 5; + const cw = Math.max(20, tw - gw); + const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES; + + const oldSrc: string[] = [], + newSrc: string[] = []; + for (const l of vis) { + if (l.type === "ctx" || l.type === "del") oldSrc.push(l.content); + if (l.type === "ctx" || l.type === "add") newSrc.push(l.content); + } + const [oldHL, newHL] = canHL + ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)]) + : [oldSrc, newSrc]; + + let oI = 0, + nI = 0, + idx = 0; + const out: string[] = []; + out.push(rule(tw)); + + function emitRow( + num: number | null, + sign: string, + gutterBg: string, + signFg: string, + body: string, + bodyBg = "", + ): void { + const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : ""; + const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : `${BG_BASE} `; + const numFg = borderFg || FG_LNUM; + const gutter = `${border}${gutterBg}${lnum(num, nw, numFg)}${signFg}${sign}${RST} ${DIVIDER} `; + const contGutter = `${border}${gutterBg}${" ".repeat(nw + 1)}${RST} ${DIVIDER} `; + const rows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), bodyBg); + out.push(`${gutter}${rows[0]}${RST}`); + for (let r = 1; r < rows.length; r++) out.push(`${contGutter}${rows[r]}${RST}`); + } + + while (idx < vis.length) { + const l = vis[idx]; + + if (l.type === "sep") { + const gap = l.newNum; + const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···"; + const totalW = Math.min(tw, 72); + const pad = Math.max(0, totalW - label.length - 2); + const half1 = Math.floor(pad / 2), + half2 = pad - half1; + out.push(`${BG_BASE}${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${RST}`); + idx++; + continue; + } + + if (l.type === "ctx") { + const hl = oldHL[oI] ?? l.content; + emitRow(l.newNum, " ", BG_BASE, dc.fgCtx, `${BG_BASE}${DIM}${hl}`, BG_BASE); + oI++; + nI++; + idx++; + continue; + } + + const dels: Array<{ l: DiffLine; hl: string }> = []; + while (idx < vis.length && vis[idx].type === "del") { + dels.push({ l: vis[idx], hl: oldHL[oI] ?? vis[idx].content }); + oI++; + idx++; + } + const adds: Array<{ l: DiffLine; hl: string }> = []; + while (idx < vis.length && vis[idx].type === "add") { + adds.push({ l: vis[idx], hl: newHL[nI] ?? vis[idx].content }); + nI++; + idx++; + } + + const isPaired = dels.length === 1 && adds.length === 1; + const wd = isPaired ? wordDiffAnalysis(dels[0].l.content, adds[0].l.content) : null; + + if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) { + const delBody = injectBg(dels[0].hl, wd.oldRanges, BG_DEL, BG_DEL_W); + const addBody = injectBg(adds[0].hl, wd.newRanges, BG_ADD, BG_ADD_W); + emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, delBody, BG_DEL); + emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, addBody, BG_ADD); + continue; + } + if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) { + const pwd = plainWordDiff(dels[0].l.content, adds[0].l.content); + emitRow(dels[0].l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, `${BG_DEL}${pwd.old}`, BG_DEL); + emitRow(adds[0].l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, `${BG_ADD}${pwd.new}`, BG_ADD); + continue; + } + + for (const d of dels) { + const body = canHL ? `${BG_DEL}${d.hl}` : `${BG_DEL}${d.l.content}`; + emitRow(d.l.oldNum, "-", BG_GUTTER_DEL, `${dc.fgDel}${BOLD}`, body, BG_DEL); + } + for (const a of adds) { + const body = canHL ? `${BG_ADD}${a.hl}` : `${BG_ADD}${a.l.content}`; + emitRow(a.l.newNum, "+", BG_GUTTER_ADD, `${dc.fgAdd}${BOLD}`, body, BG_ADD); + } + } + + out.push(rule(tw)); + if (diff.lines.length > vis.length) { + out.push(`${BG_BASE}${FG_DIM} … ${diff.lines.length - vis.length} more lines${RST}`); + } + return out.join("\n"); +} + +// --------------------------------------------------------------------------- +// Split view +// --------------------------------------------------------------------------- + +async function renderSplit( + diff: ParsedDiff, + language: BundledLanguage | undefined, + max = MAX_PREVIEW_LINES, + dc: DiffColors = DEFAULT_DIFF_COLORS, +): Promise { + const tw = termW(); + if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc); + if (!diff.lines.length) return ""; + + type Row = { left: DiffLine | null; right: DiffLine | null }; + const rows: Row[] = []; + let i = 0; + while (i < diff.lines.length) { + const l = diff.lines[i]; + if (l.type === "sep" || l.type === "ctx") { + rows.push({ left: l, right: l }); + i++; + continue; + } + const dels: DiffLine[] = [], + adds: DiffLine[] = []; + while (i < diff.lines.length && diff.lines[i].type === "del") { + dels.push(diff.lines[i]); + i++; + } + while (i < diff.lines.length && diff.lines[i].type === "add") { + adds.push(diff.lines[i]); + i++; + } + const n = Math.max(dels.length, adds.length); + for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null }); + } + + const vis = rows.slice(0, max); + const half = Math.floor((tw - 1) / 2); + const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length); + const gw = nw + 5; + const cw = Math.max(12, half - gw); + const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2; + + const leftSrc: string[] = [], + rightSrc: string[] = []; + for (const r of vis) { + if (r.left && r.left.type !== "sep") leftSrc.push(r.left.content); + if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content); + } + const [leftHL, rightHL] = canHL + ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)]) + : [leftSrc, rightSrc]; + + let lI = 0, + rI = 0; + let stripeRow = 0; + + type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] }; + + function half_build( + line: DiffLine | null, + hl: string, + ranges: Array<[number, number]> | null, + side: "left" | "right", + ): HalfResult { + if (!line) { + const gw2 = nw + 2; + const gPat = FG_STRIPE + "╱".repeat(gw2) + RST; + const g = ` ${gPat}${FG_RULE}│${RST} `; + return { gutter: g, contGutter: g, bodyRows: [stripes(cw, stripeRow)] }; + } + if (line.type === "sep") { + const gap = line.newNum; + const label = gap && gap > 0 ? `··· ${gap} lines ···` : "···"; + const g = `${BG_BASE} ${FG_DIM}${fit("", nw + 2)}${RST}${FG_RULE}│${RST} `; + return { gutter: g, contGutter: g, bodyRows: [`${BG_BASE}${FG_DIM}${fit(label, cw)}${RST}`] }; + } + + const isDel = line.type === "del", + isAdd = line.type === "add"; + const gBg = isDel ? BG_GUTTER_DEL : isAdd ? BG_GUTTER_ADD : BG_BASE; + const cBg = isDel ? BG_DEL : isAdd ? BG_ADD : BG_BASE; + const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx; + const sign = isDel ? "-" : isAdd ? "+" : " "; + const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum; + + const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : ""; + const border = borderFg ? `${borderFg}${BORDER_BAR}${RST}` : ` ${BG_BASE}`; + const numFg = borderFg || FG_LNUM; + + let body: string; + if (ranges && ranges.length > 0) { + body = injectBg(hl, ranges, cBg, isDel ? BG_DEL_W : BG_ADD_W); + } else if (isDel || isAdd) { + body = `${cBg}${hl}`; + } else { + body = `${BG_BASE}${DIM}${hl}`; + } + + const gutter = `${border}${gBg}${lnum(num, nw, numFg)}${sFg}${BOLD}${sign}${RST} ${FG_RULE}│${RST} `; + const contGutter = `${border}${gBg}${" ".repeat(nw + 1)}${RST} ${FG_RULE}│${RST} `; + const bodyRows = wrapAnsi(tabs(body), cw, adaptiveWrapRows(), cBg); + return { gutter, contGutter, bodyRows }; + } + + const out: string[] = []; + const hdrOld = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgDel}${DIM}old${RST}`; + const hdrNew = `${BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgAdd}${DIM}new${RST}`; + out.push(`${BG_BASE}${hdrOld}${" ".repeat(Math.max(0, half - nw - 1))}${FG_RULE}┊${RST}${hdrNew}`); + out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`); + + for (const r of vis) { + const leftLine = r.left, + rightLine = r.right; + const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add"; + const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null; + + let lResult: HalfResult, rResult: HalfResult; + + if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) { + const lhl = leftHL[lI++] ?? leftLine.content; + const rhl = rightHL[rI++] ?? rightLine.content; + lResult = half_build(leftLine, lhl, wd.oldRanges, "left"); + rResult = half_build(rightLine, rhl, wd.newRanges, "right"); + } else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) { + const pwd = plainWordDiff(leftLine.content, rightLine.content); + lI++; + rI++; + lResult = half_build(leftLine, pwd.old, null, "left"); + rResult = half_build(rightLine, pwd.new, null, "right"); + } else { + const lhl = leftLine && leftLine.type !== "sep" ? (leftHL[lI++] ?? leftLine?.content ?? "") : ""; + const rhl = rightLine && rightLine.type !== "sep" ? (rightHL[rI++] ?? rightLine?.content ?? "") : ""; + lResult = half_build(leftLine, lhl, null, "left"); + rResult = half_build(rightLine, rhl, null, "right"); + } + + const maxRows = Math.max(lResult.bodyRows.length, rResult.bodyRows.length); + const leftIsEmpty = !r.left; + const rightIsEmpty = !r.right; + for (let row = 0; row < maxRows; row++) { + const lg = row === 0 ? lResult.gutter : lResult.contGutter; + const rg = row === 0 ? rResult.gutter : rResult.contGutter; + const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`); + const rb = + rResult.bodyRows[row] ?? (rightIsEmpty ? stripes(cw, stripeRow) : `${BG_EMPTY}${" ".repeat(cw)}${RST}`); + out.push(`${lg}${lb}${DIVIDER}${rg}${rb}`); + stripeRow++; + } + } + + out.push(`${rule(half)}${FG_RULE}┊${RST}${rule(half)}`); + if (rows.length > vis.length) { + out.push(`${BG_BASE}${FG_DIM} … ${rows.length - vis.length} more lines${RST}`); + } + return out.join("\n"); +} + +// --------------------------------------------------------------------------- +// Public API — register write/edit tools with diff rendering +// --------------------------------------------------------------------------- + +export function registerDiffTools(pi: any): void { + applyDiffPalette(); + + let createWriteFn: any, createEditFn: any, TextComponent: any, pillFn: any, keyHintFn: any; + try { + const sdk = require("@mariozechner/pi-coding-agent"); + createWriteFn = sdk.createWriteTool; + createEditFn = sdk.createEditTool; + keyHintFn = sdk.keyHint; + TextComponent = require("@mariozechner/pi-tui").Text; + pillFn = require("./pill.js").pill; + } catch { + return; + } + if (!createWriteFn || !createEditFn || !TextComponent) return; + + const cwd = process.cwd(); + const home = process.env.HOME ?? ""; + const sp = (p: string) => shortPath(cwd, home, p); + + /** Max diff lines shown collapsed vs expanded */ + const COLLAPSED_DIFF_LINES = 30; + const COLLAPSED_NEW_FILE_LINES = 12; + + /** Expand hint text */ + function expandHint(theme: any): string { + const hint = keyHintFn ? keyHintFn("app.tools.expand", "to expand") : "expand to see more"; + return theme.fg("dim", `… (${hint})`); + } + + /** Get raw text from tool result (fallback rendering) */ + function fallbackText(result: any, theme: any): string { + const raw = result?.content + ?.filter((c: any) => c.type === "text") + .map((c: any) => c.text || "") + .join("\n") ?? ""; + return raw ? theme.fg("dim", raw.slice(0, 200)) : ""; + } + + /** Render a pill header for write/create/edit */ + function header(label: string, fp: string, theme: any): string { + const p = pillFn ? pillFn(label, theme) : theme.fg("toolTitle", theme.bold(label)); + return `${p} ${theme.fg("accent", sp(fp))}`; + } + + // ======================================================================= + // write + // ======================================================================= + + const origWrite = createWriteFn(cwd); + + pi.registerTool({ + ...origWrite, + parameters: { ...origWrite.parameters }, + + async execute(tid: string, params: any, sig: any, upd: any, ctx: any) { + const fp = params.path ?? params.file_path ?? ""; + let old: string | null = null; + try { + if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8"); + } catch { + old = null; + } + + // Store isNew decision in state so renderCall/renderResult stay consistent + if (ctx.state) ctx.state._isNewFile = old === null; + + const result = await origWrite.execute(tid, params, sig, upd, ctx); + const content = params.content ?? ""; + + try { + if (old !== null && old !== content) { + const diff = parseDiff(old, content); + const lg = lang(fp); + (result as any).details = { + _type: "diff", + summary: summarize(diff.added, diff.removed), + diff, + language: lg, + }; + } else if (old === null) { + const lineCount = content ? content.split("\n").length : 0; + (result as any).details = { + _type: "new", + lines: lineCount, + content: content ?? "", + filePath: fp, + }; + } else if (old === content) { + (result as any).details = { _type: "noChange" }; + } + } catch { + // Fallback: if diff computation fails, just report success + (result as any).details = { _type: "fallback" }; + } + return result; + }, + + renderCall(args: any, theme: any, ctx: any) { + const fp = args?.path ?? args?.file_path ?? ""; + // Use state from execute if available, otherwise check filesystem + const isNew = ctx.state?._isNewFile ?? (!fp || !existsSync(fp)); + const label = isNew ? "create" : "write"; + const text = ctx.lastComponent ?? new TextComponent("", 0, 0); + const hdr = header(label, fp, theme); + + // Streaming: show line count progress + if (args?.content && !ctx.argsComplete) { + const n = String(args.content).split("\n").length; + text.setText(`${hdr} ${theme.fg("muted", `(${n} lines…)`)}`); + return text; + } + + // Create preview: syntax-highlight new file content + if (args?.content && ctx.argsComplete && isNew) { + const previewKey = `create:${fp}:${String(args.content).length}:${ctx.expanded}`; + if (ctx.state._previewKey !== previewKey) { + ctx.state._previewKey = previewKey; + ctx.state._previewText = hdr; + const lg = lang(fp); + hlBlock(args.content, lg) + .then((lines: string[]) => { + if (ctx.state._previewKey !== previewKey) return; + const maxShow = ctx.expanded ? lines.length : COLLAPSED_NEW_FILE_LINES; + const preview = lines.slice(0, maxShow).join("\n"); + const rem = lines.length - maxShow; + let out = `${hdr}\n\n${preview}`; + if (rem > 0) out += `\n${expandHint(theme)}`; + ctx.state._previewText = out; + ctx.invalidate(); + }) + .catch(() => { + // Fallback: show plain content + const lines = args.content.split("\n"); + const maxShow = ctx.expanded ? lines.length : COLLAPSED_NEW_FILE_LINES; + const preview = lines.slice(0, maxShow).join("\n"); + const rem = lines.length - maxShow; + let out = `${hdr}\n\n${preview}`; + if (rem > 0) out += `\n${expandHint(theme)}`; + ctx.state._previewText = out; + ctx.invalidate(); + }); + } + text.setText(ctx.state._previewText ?? hdr); + return text; + } + + text.setText(hdr); + return text; + }, + + renderResult(result: any, opts: any, theme: any, ctx: any) { + const text = ctx.lastComponent ?? new TextComponent("", 0, 0); + const expanded = opts?.expanded ?? ctx.expanded ?? false; + + if (ctx.isError) { + const e = + result.content + ?.filter((c: any) => c.type === "text") + .map((c: any) => c.text || "") + .join("\n") ?? "Error"; + text.setText(`\n${theme.fg("error", e)}`); + return text; + } + + const d = result.details; + + // Diff result (overwrite of existing file) + if (d?._type === "diff") { + const w = termW(); + const maxLines = expanded ? MAX_RENDER_LINES : COLLAPSED_DIFF_LINES; + const key = `wd:${w}:${maxLines}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`; + if (ctx.state._wdk !== key) { + ctx.state._wdk = key; + ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`; + try { + const dc = resolveDiffColors(theme); + renderSplit(d.diff, d.language, maxLines, dc) + .then((rendered: string) => { + if (ctx.state._wdk !== key) return; + let out = ` ${d.summary}\n${rendered}`; + if (!expanded && d.diff.lines.length > COLLAPSED_DIFF_LINES) { + out += `\n${expandHint(theme)}`; + } + ctx.state._wdt = out; + ctx.invalidate(); + }) + .catch(() => { + if (ctx.state._wdk !== key) return; + ctx.state._wdt = ` ${d.summary}`; + ctx.invalidate(); + }); + } catch { + ctx.state._wdt = ` ${d.summary}`; + } + } + text.setText(ctx.state._wdt ?? ` ${d.summary}`); + return text; + } + + // No changes + if (d?._type === "noChange") { + text.setText(` ${theme.fg("muted", "✓ no changes")}`); + return text; + } + + // New file + if (d?._type === "new") { + const { lines: lineCount, content: rawContent, filePath: fp } = d; + const maxShow = expanded ? Infinity : COLLAPSED_NEW_FILE_LINES; + const pk = `nf:${fp}:${lineCount}:${maxShow}`; + if (ctx.state._nfk !== pk) { + ctx.state._nfk = pk; + ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`; + const lg = lang(fp); + if (rawContent) { + hlBlock(rawContent, lg) + .then((hlLines: string[]) => { + if (ctx.state._nfk !== pk) return; + const shown = hlLines.slice(0, maxShow); + const preview = shown.join("\n"); + const rem = hlLines.length - shown.length; + let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`; + if (rem > 0) out += `\n${expandHint(theme)}`; + ctx.state._nft = out; + ctx.invalidate(); + }) + .catch(() => { + // Fallback: plain text preview + const lines = rawContent.split("\n"); + const shown = lines.slice(0, maxShow); + const rem = lines.length - shown.length; + let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${shown.join("\n")}`; + if (rem > 0) out += `\n${expandHint(theme)}`; + ctx.state._nft = out; + ctx.invalidate(); + }); + } + } + text.setText(ctx.state._nft ?? ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`); + return text; + } + + // Fallback: show raw result text + const fb = fallbackText(result, theme); + text.setText(fb ? ` ${fb}` : ` ${theme.fg("dim", "written")}`); + return text; + }, + }); + + // ======================================================================= + // edit + // ======================================================================= + + const origEdit = createEditFn(cwd); + + function getEditOperations(input: any): Array<{ oldText: string; newText: string }> { + if (Array.isArray(input?.edits)) { + return input.edits + .map((edit: any) => ({ + oldText: + typeof edit?.oldText === "string" + ? edit.oldText + : typeof edit?.old_text === "string" + ? edit.old_text + : "", + newText: + typeof edit?.newText === "string" + ? edit.newText + : typeof edit?.new_text === "string" + ? edit.new_text + : "", + })) + .filter((edit: { oldText: string; newText: string }) => edit.oldText !== edit.newText); + } + + const oldText = + typeof input?.oldText === "string" + ? input.oldText + : typeof input?.old_text === "string" + ? input.old_text + : ""; + const newText = + typeof input?.newText === "string" + ? input.newText + : typeof input?.new_text === "string" + ? input.new_text + : ""; + return oldText !== newText ? [{ oldText, newText }] : []; + } + + function summarizeEditOperations(operations: Array<{ oldText: string; newText: string }>) { + const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText)); + const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0); + const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0); + return { + diffs, + totalAdded, + totalRemoved, + summary: summarize(totalAdded, totalRemoved), + }; + } + + pi.registerTool({ + ...origEdit, + parameters: { ...origEdit.parameters }, + + async execute(tid: string, params: any, sig: any, upd: any, ctx: any) { + const fp = params.path ?? params.file_path ?? ""; + const operations = getEditOperations(params); + + // Read old content BEFORE executing, to find correct line offset + let preEditContent: string | null = null; + try { + if (fp && existsSync(fp)) preEditContent = readFileSync(fp, "utf-8"); + } catch { + preEditContent = null; + } + + const result = await origEdit.execute(tid, params, sig, upd, ctx); + + if (operations.length === 0) return result; + + try { + const { diffs, summary } = summarizeEditOperations(operations); + if (operations.length === 1) { + let editLine = 0; + // Use pre-edit content to find oldText position (not newText) + if (preEditContent && operations[0].oldText) { + const idx = preEditContent.indexOf(operations[0].oldText); + if (idx >= 0) editLine = preEditContent.slice(0, idx).split("\n").length; + } + (result as any).details = { _type: "editInfo", summary, editLine }; + return result; + } + + (result as any).details = { + _type: "multiEditInfo", + summary, + editCount: operations.length, + diffLineCount: diffs.reduce((sum, diff) => sum + diff.lines.length, 0), + }; + } catch { + (result as any).details = { _type: "fallback" }; + } + return result; + }, + + renderCall(args: any, theme: any, ctx: any) { + const fp = args?.path ?? args?.file_path ?? ""; + const operations = getEditOperations(args); + const text = ctx.lastComponent ?? new TextComponent("", 0, 0); + const hdr = header("edit", fp, theme); + + if (!(ctx.argsComplete && operations.length > 0)) { + text.setText(hdr); + return text; + } + + const expanded = ctx.expanded ?? false; + const pk = JSON.stringify({ fp, operations, w: termW(), expanded }); + if (ctx.state._pk !== pk) { + ctx.state._pk = pk; + ctx.state._pt = `${hdr} ${theme.fg("muted", "(rendering…)")}`; + const lg = lang(fp); + const dc = resolveDiffColors(theme); + const maxLines = expanded ? MAX_PREVIEW_LINES : COLLAPSED_DIFF_LINES; + + if (operations.length === 1) { + const diff = parseDiff(operations[0].oldText, operations[0].newText); + renderSplit(diff, lg, maxLines, dc) + .then((rendered) => { + if (ctx.state._pk !== pk) return; + let out = `${hdr}\n${summarize(diff.added, diff.removed)}\n${rendered}`; + if (!expanded && diff.lines.length > COLLAPSED_DIFF_LINES) { + out += `\n${expandHint(theme)}`; + } + ctx.state._pt = out; + ctx.invalidate(); + }) + .catch(() => { + if (ctx.state._pk !== pk) return; + ctx.state._pt = `${hdr} ${summarize(diff.added, diff.removed)}`; + ctx.invalidate(); + }); + } else { + const { diffs, summary } = summarizeEditOperations(operations); + const maxShown = Math.min(operations.length, 3); + const previewLines = Math.max(8, Math.floor(maxLines / maxShown)); + Promise.all( + diffs.slice(0, maxShown).map((diff, index) => + renderSplit(diff, lg, previewLines, dc) + .then((rendered) => `Edit ${index + 1}/${operations.length}\n${rendered}`) + .catch(() => `Edit ${index + 1}/${operations.length} ${summarize(diff.added, diff.removed)}`), + ), + ) + .then((sections) => { + if (ctx.state._pk !== pk) return; + const remainder = operations.length - maxShown; + const suffix = + remainder > 0 + ? `\n${theme.fg("muted", `… ${remainder} more edit blocks`)}` + : ""; + ctx.state._pt = `${hdr}\n${operations.length} edits ${summary}\n\n${sections.join("\n\n")}${suffix}`; + ctx.invalidate(); + }) + .catch(() => { + if (ctx.state._pk !== pk) return; + ctx.state._pt = `${hdr} ${operations.length} edits ${summary}`; + ctx.invalidate(); + }); + } + } + + text.setText(ctx.state._pt ?? hdr); + return text; + }, + + renderResult(result: any, opts: any, theme: any, ctx: any) { + const text = ctx.lastComponent ?? new TextComponent("", 0, 0); + const expanded = opts?.expanded ?? ctx.expanded ?? false; + + if (ctx.isError) { + const e = + result.content + ?.filter((c: any) => c.type === "text") + .map((c: any) => c.text || "") + .join("\n") ?? "Error"; + text.setText(`\n${theme.fg("error", e)}`); + return text; + } + + if (result.details?._type === "editInfo") { + const { summary: s, editLine } = result.details; + const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : ""; + text.setText(` ${s}${loc}`); + return text; + } + + if (result.details?._type === "multiEditInfo") { + const { summary: s, editCount, diffLineCount } = result.details; + const dlInfo = + typeof diffLineCount === "number" + ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` + : ""; + text.setText(` ${editCount} edits ${s}${dlInfo}`); + return text; + } + + // Fallback + const fb = fallbackText(result, theme); + text.setText(fb ? ` ${fb}` : ` ${theme.fg("dim", "edited")}`); + return text; + }, + }); +} diff --git a/extensions/tool-pills/index.ts b/extensions/tool-pills/index.ts new file mode 100644 index 0000000..6099847 --- /dev/null +++ b/extensions/tool-pills/index.ts @@ -0,0 +1,139 @@ +/** + * Tool Pills + Diff Renderer — combined extension. + * + * • ls, read, find, grep, bash → colored pill labels + collapsed output + * • write, edit → Shiki-powered syntax-highlighted diffs (from pi-diff) + */ + +import type { ExtensionAPI, AgentToolResult, Theme } from "@mariozechner/pi-coding-agent"; +import { + createBashToolDefinition, + createFindToolDefinition, + createGrepToolDefinition, + createLsToolDefinition, + createReadToolDefinition, + highlightCode, + keyHint, +} from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { pill } from "./pill.js"; +import { registerDiffTools } from "./diff-renderer.js"; + +/** Max lines shown in collapsed (non-expanded) result view */ +const COLLAPSED_MAX_LINES = 15; + +/** Extract the first text content from a tool result */ +function getText(result: AgentToolResult): string | undefined { + const c = result.content.find((c) => c.type === "text"); + return c?.type === "text" ? c.text : undefined; +} + +/** + * Render tool output text with collapsed truncation + expand hint. + */ +function renderTextResult( + text: string | undefined, + expanded: boolean, + theme: Theme, + mode: "head" | "tail" = "head", +): Text { + if (!text || !text.trim()) return new Text("", 0, 0); + + const lines = text.split("\n"); + + if (expanded || lines.length <= COLLAPSED_MAX_LINES) { + const output = lines.map((l) => theme.fg("toolOutput", l)).join("\n"); + return new Text(`\n${output}`, 0, 0); + } + + const hidden = lines.length - COLLAPSED_MAX_LINES; + const hint = theme.fg("dim", `… ${hidden} more lines (${keyHint("app.tools.expand", "to expand")})`); + + if (mode === "tail") { + const visible = lines.slice(-COLLAPSED_MAX_LINES); + const output = visible.map((l) => theme.fg("toolOutput", l)).join("\n"); + return new Text(`\n${hint}\n${output}`, 0, 0); + } + + const visible = lines.slice(0, COLLAPSED_MAX_LINES); + const output = visible.map((l) => theme.fg("toolOutput", l)).join("\n"); + return new Text(`\n${output}\n${hint}`, 0, 0); +} + +/** Helper to register a basic tool (ls, read, find, grep) with pill + collapsed output. */ +function wrapBasicTool( + pi: ExtensionAPI, + orig: any, + name: string, + mkCallText: (args: any, theme: Theme) => string, + mode: "head" | "tail" = "head", +) { + pi.registerTool({ + ...orig, + parameters: { ...orig.parameters }, + renderCall(args: any, theme: Theme, _ctx: any) { + return new Text(pill(name, theme) + " " + mkCallText(args, theme), 0, 0); + }, + renderResult(result: any, { expanded }: { expanded: boolean }, theme: Theme, _ctx: any) { + return renderTextResult(getText(result), expanded, theme, mode); + }, + }); +} + +export default function (pi: ExtensionAPI) { + const cwd = process.cwd(); + + // ls + wrapBasicTool(pi, createLsToolDefinition(cwd), "ls", (args, theme) => + theme.fg("accent", args.path || "."), + ); + + // read + wrapBasicTool(pi, createReadToolDefinition(cwd), "read", (args, theme) => { + let t = theme.fg("accent", args.path); + if (args.offset || args.limit) { + const parts: string[] = []; + if (args.offset) parts.push(`L${args.offset}`); + if (args.limit) parts.push(`${args.limit}L`); + t += theme.fg("dim", ` ${parts.join(", ")}`); + } + return t; + }); + + // find + wrapBasicTool(pi, createFindToolDefinition(cwd), "find", (args, theme) => { + let t = theme.fg("accent", `"${args.pattern}"`); + if (args.path) t += theme.fg("dim", ` in ${args.path}`); + return t; + }); + + // grep + wrapBasicTool(pi, createGrepToolDefinition(cwd), "grep", (args, theme) => { + let t = theme.fg("accent", `"${args.pattern}"`); + if (args.path) t += theme.fg("dim", ` in ${args.path}`); + if (args.glob) t += theme.fg("dim", ` ${args.glob}`); + return t; + }); + + // bash — special: syntax-highlighted command, tail mode + const origBash = createBashToolDefinition(cwd); + pi.registerTool({ + ...origBash, + parameters: { ...origBash.parameters }, + renderCall(args: any, theme: Theme, _ctx: any) { + const cmd = args.command; + const highlighted = highlightCode(cmd, "bash").join("\n"); + const isMultiLine = cmd.includes("\n") || cmd.length > 80; + if (isMultiLine) { + return new Text(pill("bash", theme) + "\n" + highlighted, 0, 0); + } + return new Text(pill("bash", theme) + " " + highlighted, 0, 0); + }, + renderResult(result: any, { expanded }: { expanded: boolean }, theme: Theme, _ctx: any) { + return renderTextResult(getText(result), expanded, theme, "tail"); + }, + }); + + // write + edit — diff renderer with pills, expand/collapse, fallbacks + registerDiffTools(pi); +} diff --git a/extensions/tool-pills/package-lock.json b/extensions/tool-pills/package-lock.json new file mode 100644 index 0000000..f26ad4e --- /dev/null +++ b/extensions/tool-pills/package-lock.json @@ -0,0 +1,630 @@ +{ + "name": "tool-pills", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tool-pills", + "dependencies": { + "@shikijs/cli": "^4.0.2", + "diff": "^7.0.0" + }, + "devDependencies": { + "@types/diff": "^7.0.2" + } + }, + "node_modules/@shikijs/cli": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/cli/-/cli-4.0.2.tgz", + "integrity": "sha512-1aj4yHpsYQ6ETF3/Pjz6FlejYIknnpzAV4YZJhgBm858b+6OtjmJK2LRJYF4UG/S+ijuHeUe2jyR1orXYfob+w==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "ansis": "^4.2.0", + "cac": "^7.0.0", + "shiki": "4.0.2" + }, + "bin": { + "cli": "bin.mjs", + "shiki": "bin.mjs", + "shiki-cli": "bin.mjs", + "skat": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/cac": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", + "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/extensions/tool-pills/package.json b/extensions/tool-pills/package.json new file mode 100644 index 0000000..9e89d12 --- /dev/null +++ b/extensions/tool-pills/package.json @@ -0,0 +1,11 @@ +{ + "name": "tool-pills", + "private": true, + "dependencies": { + "diff": "^7.0.0", + "@shikijs/cli": "^4.0.2" + }, + "devDependencies": { + "@types/diff": "^7.0.2" + } +} diff --git a/extensions/tool-pills/pill.ts b/extensions/tool-pills/pill.ts new file mode 100644 index 0000000..eb71195 --- /dev/null +++ b/extensions/tool-pills/pill.ts @@ -0,0 +1,24 @@ +/** + * Shared pill badge renderer for tool headers. + * + * Produces an inverted-colour badge like ` write ` using theme semantic roles. + */ +import type { Theme } from "@mariozechner/pi-coding-agent"; + +/** Map tool name → theme semantic colour role for the pill badge. */ +const TOOL_ROLES: Record = { + ls: "success", + read: "success", + find: "mdCode", + grep: "mdCode", + bash: "error", + write: "accent", + create: "accent", + edit: "warning", +}; + +/** Render an inverted-colour pill badge: ` name ` */ +export function pill(name: string, theme: Theme): string { + const role = TOOL_ROLES[name] ?? "dim"; + return theme.bold(theme.inverse(theme.fg(role as any, ` ${name} `))); +} diff --git a/extensions/tool-pills/tool-pills.ts b/extensions/tool-pills/tool-pills.ts deleted file mode 100644 index 618fbe3..0000000 --- a/extensions/tool-pills/tool-pills.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Tool Pills — Colored inverted-text pill labels for built-in tool calls. - * - * Re-registers each built-in tool with a custom `renderCall` that shows - * a compact colored pill + contextual arguments, and a `renderResult` - * that truncates long output when collapsed (Ctrl+O to expand). - */ - -import type { ExtensionAPI, AgentToolResult, Theme } from "@mariozechner/pi-coding-agent"; -import { - createBashToolDefinition, - createEditToolDefinition, - createFindToolDefinition, - createGrepToolDefinition, - createLsToolDefinition, - createReadToolDefinition, - createWriteToolDefinition, - highlightCode, - keyHint, -} from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; - -/* ── constants ──────────────────────────────────────────────────── */ - -/** Max lines shown in collapsed (non-expanded) result view */ -const COLLAPSED_MAX_LINES = 15; - -/* ── Tool pill theme roles ───────────────────────────────────────── */ - -const TOOL_ROLES: Record = { - ls: "success", // green - read: "success", // green - find: "mdCode", // teal - grep: "mdCode", // teal - edit: "warning", // yellow - write: "bashMode", // peach - bash: "error", // red -}; - -/** Render an inverted-colour pill badge: ` name ` using theme colors. */ -function pill(name: string, theme: Theme): string { - const role = TOOL_ROLES[name] ?? "dim"; - return theme.bold(theme.inverse(theme.fg(role as any, ` ${name} `))); -} - -/* ── result rendering helpers ───────────────────────────────────── */ - -/** Extract the first text content from a tool result */ -function getText(result: AgentToolResult): string | undefined { - const c = result.content.find((c) => c.type === "text"); - return c?.type === "text" ? c.text : undefined; -} - -/** - * Render tool output text with collapsed truncation. - * - Collapsed: shows first COLLAPSED_MAX_LINES, with a hint to expand. - * - Expanded: shows everything. - * For "tail" mode (e.g. bash), shows the *last* N lines when collapsed. - */ -function renderTextResult( - text: string | undefined, - expanded: boolean, - theme: Theme, - mode: "head" | "tail" = "head", -): Text { - if (!text || !text.trim()) return new Text("", 0, 0); - - const lines = text.split("\n"); - - if (expanded || lines.length <= COLLAPSED_MAX_LINES) { - const output = lines.map((l) => theme.fg("toolOutput", l)).join("\n"); - return new Text(`\n${output}`, 0, 0); - } - - // Truncate - const hidden = lines.length - COLLAPSED_MAX_LINES; - const hint = theme.fg("dim", `… ${hidden} more lines (${keyHint("app.tools.expand", "to expand")})`); - - if (mode === "tail") { - const visible = lines.slice(-COLLAPSED_MAX_LINES); - const output = visible.map((l) => theme.fg("toolOutput", l)).join("\n"); - return new Text(`\n${hint}\n${output}`, 0, 0); - } - - // head (default) - const visible = lines.slice(0, COLLAPSED_MAX_LINES); - const output = visible.map((l) => theme.fg("toolOutput", l)).join("\n"); - return new Text(`\n${output}\n${hint}`, 0, 0); -} - -/* ── extension entry point ──────────────────────────────────────── */ - -export default function (pi: ExtensionAPI) { - const cwd = process.cwd(); - - /* ls */ - const origLs = createLsToolDefinition(cwd); - pi.registerTool({ - ...origLs, - parameters: { ...origLs.parameters }, - renderCall(args, theme, _ctx) { - return new Text(pill("ls", theme) + " " + theme.fg("accent", args.path || "."), 0, 0); - }, - renderResult(result, { expanded }, theme, _ctx) { - return renderTextResult(getText(result), expanded, theme); - }, - }); - - /* read */ - const origRead = createReadToolDefinition(cwd); - pi.registerTool({ - ...origRead, - parameters: { ...origRead.parameters }, - renderCall(args, theme, _ctx) { - let t = pill("read", theme) + " " + theme.fg("accent", args.path); - if (args.offset || args.limit) { - const parts: string[] = []; - if (args.offset) parts.push(`L${args.offset}`); - if (args.limit) parts.push(`${args.limit}L`); - t += theme.fg("dim", ` ${parts.join(", ")}`); - } - return new Text(t, 0, 0); - }, - renderResult(result, { expanded }, theme, _ctx) { - return renderTextResult(getText(result), expanded, theme); - }, - }); - - /* find */ - const origFind = createFindToolDefinition(cwd); - pi.registerTool({ - ...origFind, - parameters: { ...origFind.parameters }, - renderCall(args, theme, _ctx) { - let t = pill("find", theme) + " " + theme.fg("accent", `"${args.pattern}"`); - if (args.path) t += theme.fg("dim", ` in ${args.path}`); - return new Text(t, 0, 0); - }, - renderResult(result, { expanded }, theme, _ctx) { - return renderTextResult(getText(result), expanded, theme); - }, - }); - - /* grep */ - const origGrep = createGrepToolDefinition(cwd); - pi.registerTool({ - ...origGrep, - parameters: { ...origGrep.parameters }, - renderCall(args, theme, _ctx) { - let t = pill("grep", theme) + " " + theme.fg("accent", `"${args.pattern}"`); - if (args.path) t += theme.fg("dim", ` in ${args.path}`); - if (args.glob) t += theme.fg("dim", ` ${args.glob}`); - return new Text(t, 0, 0); - }, - renderResult(result, { expanded }, theme, _ctx) { - return renderTextResult(getText(result), expanded, theme); - }, - }); - - /* edit */ - const origEdit = createEditToolDefinition(cwd); - pi.registerTool({ - ...origEdit, - parameters: { ...origEdit.parameters }, - renderCall(args, theme, _ctx) { - return new Text(pill("edit", theme) + " " + theme.fg("accent", args.path), 0, 0); - }, - renderResult(result, { expanded }, theme, _ctx) { - return renderTextResult(getText(result), expanded, theme); - }, - }); - - /* write */ - const origWrite = createWriteToolDefinition(cwd); - pi.registerTool({ - ...origWrite, - parameters: { ...origWrite.parameters }, - renderCall(args, theme, _ctx) { - return new Text(pill("write", theme) + " " + theme.fg("accent", args.path), 0, 0); - }, - renderResult(result, { expanded }, theme, _ctx) { - return renderTextResult(getText(result), expanded, theme); - }, - }); - - /* bash */ - const origBash = createBashToolDefinition(cwd); - pi.registerTool({ - ...origBash, - parameters: { ...origBash.parameters }, - renderCall(args, theme, _ctx) { - const cmd = args.command; - const highlighted = highlightCode(cmd, "bash").join("\n"); - const isMultiLine = cmd.includes("\n") || cmd.length > 80; - if (isMultiLine) { - return new Text(pill("bash", theme) + "\n" + highlighted, 0, 0); - } - return new Text(pill("bash", theme) + " " + highlighted, 0, 0); - }, - renderResult(result, { expanded }, theme, _ctx) { - // Bash output: show tail when collapsed (most recent output is most useful) - return renderTextResult(getText(result), expanded, theme, "tail"); - }, - }); -} diff --git a/package-lock.json b/package-lock.json index ed2c856..120b3e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,15 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@benvargas/pi-claude-code-use": "^1.0.1", "@mariozechner/pi-ai": "^0.62.0", "@mariozechner/pi-coding-agent": "^0.62.0", - "@mariozechner/pi-tui": "^0.62.0" + "@mariozechner/pi-tui": "^0.62.0", + "@sting8k/pi-vcc": "^0.3.5", + "@tintinweb/pi-tasks": "^0.4.2" + }, + "optionalDependencies": { + "@ataraxy-labs/sem": "^0.3.21" } }, "node_modules/@anthropic-ai/sdk": { @@ -34,6 +40,20 @@ } } }, + "node_modules/@ataraxy-labs/sem": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@ataraxy-labs/sem/-/sem-0.3.21.tgz", + "integrity": "sha512-JZ+X9Jm+5lYczq/CsshKIAWq93WkELgGKfOv6Uf8T9c1TsgHZVFmSYERvnrb/493g1HIVn1UW3wCYyHEcQ9jtQ==", + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "bin": { + "sem": "bin/sem.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -736,6 +756,20 @@ "node": ">=6.9.0" } }, + "node_modules/@benvargas/pi-claude-code-use": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@benvargas/pi-claude-code-use/-/pi-claude-code-use-1.0.1.tgz", + "integrity": "sha512-R7VgxMew5rWz3Jj1vQKra86MpxX4mzb4ohgRpt7ZFtzIejV6CDNf+fxv68IQW2Zz/2bghmdh0XL/RSzkcjPkDQ==", + "license": "MIT", + "dependencies": { + "@mariozechner/jiti": "^2.6.5", + "@mariozechner/pi-ai": "*", + "@sinclair/typebox": "*" + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": ">=0.57.0" + } + }, "node_modules/@borewit/text-codec": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", @@ -1885,6 +1919,26 @@ "node": ">=18.0.0" } }, + "node_modules/@sting8k/pi-vcc": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@sting8k/pi-vcc/-/pi-vcc-0.3.5.tgz", + "integrity": "sha512-tG+tCtvlkezIWEc2Q5px59RlUjxBmefmmNWsb4nwTsD5c1WWqpfkcEewQOoeQ1A1kotfnxVIq0eeOXjEzNjftA==", + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*", + "@sinclair/typebox": "*" + } + }, + "node_modules/@tintinweb/pi-tasks": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tintinweb/pi-tasks/-/pi-tasks-0.4.2.tgz", + "integrity": "sha512-H8TwN13HHYOqwBHwLWPfALOHehWS2UxmCTcKUmHsZnoAd087hElnuz7GUqwBR44lzxp3TCSagUikOhPfrRLGlQ==", + "license": "MIT", + "dependencies": { + "@mariozechner/pi-coding-agent": "^0.62.0", + "@mariozechner/pi-tui": "^0.62.0", + "@sinclair/typebox": "latest" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", diff --git a/package.json b/package.json index 4265a43..ee30fb7 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,15 @@ "name": "pi-ext", "version": "0.1.0", "dependencies": { + "@benvargas/pi-claude-code-use": "^1.0.1", "@mariozechner/pi-ai": "^0.62.0", "@mariozechner/pi-coding-agent": "^0.62.0", - "@mariozechner/pi-tui": "^0.62.0" + "@mariozechner/pi-tui": "^0.62.0", + "@sting8k/pi-vcc": "^0.3.5", + "@tintinweb/pi-tasks": "^0.4.2" + }, + "optionalDependencies": { + "@ataraxy-labs/sem": "^0.3.21" }, "description": "Personal collection of Pi extensions, themes, and skills.", "keywords": [ @@ -13,27 +19,32 @@ "pi-coding-agent" ], "license": "MIT", + "scripts": { + "sem:evaluate": "node extensions/pi-sem/bin/sem-eval.mjs", + "test:pi-sem": "node --test extensions/pi-sem/core.test.mjs extensions/review/sem-guidance.test.mjs" + }, "pi": { "extensions": [ "./extensions/custom-footer/custom-footer.ts", "./extensions/review/review.ts", "./extensions/leader-key/index.ts", - "./extensions/session-switch/index.ts", "./extensions/permissions/permissions.ts", "./extensions/session-query/session-query.ts", "./extensions/ask-user-question/index.ts", - "./extensions/tool-pills/tool-pills.ts", + "./extensions/tool-pills/index.ts", "./extensions/cmux/index.ts", "./extensions/session-snap/index.ts", "./extensions/pi-telescope/index.ts", - "./extensions/aine/index.ts" + "./extensions/handoff/index.ts", + "./extensions/pi-sem/index.ts" ], "skills": [ "./skills/commit", "./skills/github", "./skills/visit-webpage", "./skills/web-search", - "./skills/session-query" + "./skills/session-query", + "./skills/sem" ], "themes": [ "./themes" diff --git a/skills/sem/SKILL.md b/skills/sem/SKILL.md new file mode 100644 index 0000000..84ac822 --- /dev/null +++ b/skills/sem/SKILL.md @@ -0,0 +1,72 @@ +--- +name: sem +description: Entity-aware code change analysis using the pi-sem tools. Use when the user asks what changed, wants blast radius or affected tests, needs focused context for a function/class, wants review help on a commit/branch/PR, or asks to compare semantic diff with raw git diff. Prefer sem_context and sem_impact; use sem_diff selectively for summaries and reviews. +--- + +# sem + +Use the `pi-sem` tools as a **semantic lens**, not as a universal replacement for raw git diff. + +## Default decision tree + +Choose the smallest useful tool first: + +1. **Focused understanding of one entity** → `sem_context` + - Best for a single function, method, class, block, or config section + - Prefer this before reading a whole large file + +2. **Blast radius / affected tests / hidden dependents** → `sem_impact` + - Use when reasoning about what could break + - Prefer `scope=tests` for test selection + - Prefer `scope=all` when validating broader impact + +3. **Structural inventory of a file** → `sem_entities` + - Use before drilling into a suspicious file + - Good for large files and mixed code/config files + +4. **What changed across a commit/range/working tree** → `sem_diff` + - Use for semantic summaries, entity counts, and review overviews + - Do **not** default to it when you only need exact patch details or a single entity + +5. **History / ownership of an entity** → `sem_log`, `sem_blame` + - Use for regressions, archaeology, and ownership questions + +## Review workflow + +For commit / branch / PR review: + +1. Run `sem_diff` once for a semantic overview +2. Pick the riskiest changed entities +3. Run `sem_impact` on those entities +4. Run `sem_context` on the suspicious ones you need to understand deeply +5. Confirm final findings with raw `git diff`, `read`, or direct file inspection before citing line numbers + +For snapshot / folder review: + +1. Start with `sem_entities` +2. Use `sem_context` on the most relevant entities +3. Use `sem_impact` only after you identify something suspicious + +## Important caveats + +- `sem diff --format json` is **not always smaller** than raw `git diff` +- `sem` may under-cover tests, assets, generated files, or non-semantic glue code +- Do not cite `sem` output alone as final evidence for line-level review comments +- If semantic coverage looks incomplete, fall back to raw `git diff`, `read`, `grep`, and file inspection + +## Good prompts / tool choices + +- “What changed in this commit?” → `sem_diff` +- “What tests are affected by this function?” → `sem_impact` with `scope=tests` +- “Give me focused context for this class without reading the whole file.” → `sem_context` +- “What is in this Terraform file?” → `sem_entities` +- “How did this function evolve?” → `sem_log` + +## Anti-patterns + +Avoid these habits: + +- Running `sem_diff` repeatedly when `sem_context` would answer the question faster +- Using only raw `git diff` for entity counts or blast radius questions +- Treating `sem_impact` as infallible; verify surprising results with file reads and grep +- Writing review findings from semantic summaries without checking the actual changed code -- 2.51.2 From 3ff029c600ef12775b134fa9a8e1c51cb87b142f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Tom=C5=A1ej?= Date: Fri, 17 Apr 2026 16:01:36 +0200 Subject: [PATCH 19/66] docs: rewrite README to match current extensions and skills --- README.md | 73 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index fa2eacc..1feace6 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ https://github.com/user-attachments/assets/d1e5f848-176f-43cb-85bb-3d518e5b0bdd A collection of extensions, skills, and themes for [Pi](https://github.com/badlogic/pi), the AI coding agent for the terminal. -Extensions range from visual UI enhancements (custom footer, leader key palette, session switcher) to deep integrations (web search, code review, Ghostty terminal support). Everything is MIT licensed and designed to be installed together or individually. +Extensions cover everything from UI polish (custom footer, tool pills, leader-key palette) to deep workflow tooling (semantic git review, session archiving, context handoff between sessions, cmux integration). Everything is MIT licensed and designed to be installed together or individually. -> *Leader key palette → model switcher → session picker → code review* +> *Leader key palette → fuzzy finder → semantic review → handoff to a fresh session* ## Install @@ -39,20 +39,13 @@ Requires [Pi](https://github.com/badlogic/pi) v0.37.3+. ### [Leader Key](extensions/leader-key/) -Press `Ctrl+Space` to open a floating command palette — like Vim's which-key or Emacs' leader key. Actions are organized into single-character groups (`s` for Session, `m` for Model, `f` for Favourites, `t` for Thinking level). Auto-discovers extension commands and merges them with built-in actions. +Press `Ctrl+Space` to open a floating command palette — like Vim's which-key or Emacs' leader key. Actions are organized into single-character groups (`s` for Session, `m` for Model, `f` for Favourites, `t` for Thinking level, `l` for Labels). Auto-discovers extension commands and merges them with built-in actions. Includes sub-modules: - **Model Switcher** — searchable provider → model → thinking level picker - **Favourite Models** — quick-switch to preset model+thinking combos via `favourite-models.json` - **Thinking Picker** — adjust reasoning effort (off, minimal, low, medium, high, xhigh) - -### [Session Switcher](extensions/session-switch/) - -Split-panel session picker with Pi's native session selector on the left and a live conversation preview on the right. Search, rename, delete, and resume sessions without leaving the TUI. - -### [Model Switcher](extensions/model-switcher/) - -Standalone multi-step model selector with searchable lists: pick a provider, pick a model, pick a thinking level. Available as `/switch` command or `Ctrl+Shift+M`. Also used internally by the leader key extension. +- **Session / Label Actions** — rename, archive, label, and jump between sessions ### [Custom Footer](extensions/custom-footer/) @@ -64,39 +57,25 @@ Replaces Pi's default footer with a compact powerline-style status bar: Shows working directory, git branch, token usage, cost, context window utilization, and active model — all in a single line. -### [Pi Web Access](extensions/pi-web-access/) - -Web search, content extraction, and video understanding. Zero config if you're signed into Google in Chrome, or bring your own Perplexity/Gemini API keys. Based on [pi-web-access](https://github.com/nicobailon/pi-web-access) by [Nico Bailon](https://github.com/nicobailon). - -**Tools:** `web_search`, `fetch_content`, `get_search_content` +### [Tool Pills](extensions/tool-pills/) -Capabilities: Perplexity & Gemini search, GitHub repo cloning, YouTube video understanding, local video analysis, PDF extraction, blocked-page fallbacks, interactive search curator UI. - -See the [full README](extensions/pi-web-access/README.md) for details. +Compact colored pill labels for built-in tools (`ls`, `read`, `find`, `grep`, `bash`) with collapsed output, plus Shiki-powered syntax-highlighted diffs for `write` and `edit`. Makes long tool outputs scannable without losing detail on demand. ### [Code Review](extensions/review/) `/review` command with multiple modes: review a GitHub PR (checks it out locally), diff against a base branch, review uncommitted changes, review a specific commit, or provide custom review instructions. Supports project-specific `REVIEW_GUIDELINES.md`. -When `pi-sem` is also loaded, `/review` now nudges the agent toward a semantic workflow: -- `sem_diff` for one overview of changed entities +When `pi-sem` is also loaded, `/review` nudges the agent toward a semantic workflow: +- `sem_diff` for a one-shot overview of changed entities - `sem_impact` for blast radius / affected tests on risky entities - `sem_context` for focused understanding of suspicious functions or classes - raw `git diff` / `read` for final line-level evidence Derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff) (Apache 2.0). -### [Todos](extensions/todos/) - -File-based todo management stored in `.pi/todos/`. Each todo is a standalone markdown file with JSON front matter. Supports claiming (lock files), tagging, status tracking, and automatic GC of closed items. Comes with both a `/todos` TUI and LLM-facing tools (`todo` tool) for natural language task management. Derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff) (Apache 2.0). - -### [Context](extensions/context/) - -`/context` command showing what's loaded in the current session: extensions, skills, project context files (`AGENTS.md`/`CLAUDE.md`), context window usage, and session cost totals. Derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff) (Apache 2.0). - ### [pi-sem](extensions/pi-sem/) -Semantic Git tooling for Pi powered by [sem](https://github.com/Ataraxy-Labs/sem). Exposes entity-aware tools like `sem_diff`, `sem_impact`, `sem_context`, `sem_log`, `sem_entities`, `sem_blame`, and `sem_eval` so the agent can reason about functions, classes, and config properties instead of raw line hunks. +Semantic git tooling powered by [sem](https://github.com/Ataraxy-Labs/sem). Exposes entity-aware tools — `sem_diff`, `sem_impact`, `sem_context`, `sem_log`, `sem_entities`, `sem_blame`, and `sem_eval` — so the agent can reason about functions, classes, and config properties instead of raw line hunks. Includes a local evaluator to compare `sem diff` vs `git diff` on the same selection: @@ -105,9 +84,33 @@ npm run sem:evaluate -- --staged npm run sem:evaluate -- --from origin/main --to HEAD ``` -### [Ghostty](extensions/ghostty/) +### [Pi-Telescope](extensions/pi-telescope/) + +Native TUI fuzzy finder, inspired by telescope.nvim and [Television](https://github.com/alexpasmantier/television). Fuzzy search with pattern modifiers (`'exact`, `^prefix`, `suffix$`, `!negate`), multi-select, provider switching, preview toggle, frecency-aware sorting, and provider-specific actions. Bound to `Ctrl+Space` by default. + +### [Session Snap](extensions/session-snap/) + +Session archiver and cleaner. `/snap` scans all sessions, classifies them (delete trivial ones, archive old ones, keep active ones), and lets you review before executing. `/archive` browses archived sessions with search, restore, and permanent delete. + +### [Session Query](extensions/session-query/) + +Gives the model a tool to query previous pi sessions for context, decisions, or code changes. Uses an uncapped VCC summary (~9K tokens) by default, with optional `detailed: true` mode (~80K tokens) for queries that need exact file contents or tool output. Works with the handoff extension to let a new session look up details from its parent. + +### [Handoff](extensions/handoff/) + +`/handoff ` transfers context to a fresh pi session running in a new cmux tab. Uses pi-vcc's algorithmic compaction (no LLM calls) to build a summary, plus algorithmic extraction of git state, working files, and language detection. Includes current tasks from pi-tasks. The new session starts with the summary + goal as its initial prompt. + +### [Permissions](extensions/permissions/) + +Three-mode permission system: `yolo` (everything allowed), `safe` (rule-based checks, asks for unknown bash commands), `read-only` (no repo/home writes, built-in edits restricted to `/tmp`, bash restricted to safe read-only commands). `/mode [yolo|safe|read-only]` to switch. Rules merge project (`.agents/permissions.json`) → global (`~/.pi/agent/permissions.json`) → built-ins. + +### [cmux](extensions/cmux/) + +Native integration with [cmux](https://github.com/badlogic/cmux). Context-aware notifications via the cmux socket API, sidebar status pills (model, state, thinking, tokens), and custom tools for the model (browser, workspace, notify). Silent no-op when not running inside cmux. + +### [Ask User Question](extensions/ask-user-question/) -[Ghostty](https://ghostty.org) terminal integration. Sets dynamic window titles with project, session, and model info. Animates a braille spinner and pulses Ghostty's native progress bar while the agent is working. Based on [pi-ghostty](https://github.com/HazAT/pi-ghostty) by [HazAT](https://github.com/HazAT). +Registers an `ask_user_question` tool the model uses to ask 1–4 structured clarifying questions (with 2–4 options each) instead of asking in plain text. Interactive UI with optional multi-select and short header labels for a tab bar. ## Skills @@ -116,6 +119,9 @@ npm run sem:evaluate -- --from origin/main --to HEAD | [commit](skills/commit/) | Conventional Commits-style `git commit` — infers type, scope, and summary from the diff | | [github](skills/github/) | Recipes for the `gh` CLI — PR checks, CI runs, issue queries, JSON output | | [sem](skills/sem/) | Entity-aware change analysis workflow — prefer `sem_context` and `sem_impact`, use `sem_diff` selectively for summaries and reviews | +| [session-query](skills/session-query/) | Guide for querying past pi sessions via the `session-query` tool | +| [visit-webpage](skills/visit-webpage/) | Fetch and extract content from a URL as markdown (via Jina Reader), or download images | +| [web-search](skills/web-search/) | Lightweight web search via the Jina Search API — no browser required | ## Themes @@ -128,8 +134,7 @@ npm run sem:evaluate -- --from origin/main --to HEAD Most extensions work out of the box. Notable config: - **Leader Key** — edit `extensions/leader-key/favourite-models.json` to set your favourite model presets -- **Pi Web Access** — optionally configure API keys in `~/.pi/web-search.json` (see [README](extensions/pi-web-access/README.md#configuration)) -- **Todos** — set `PI_TODO_PATH` env var to change the storage directory (defaults to `.pi/todos`) +- **Permissions** — edit `~/.pi/agent/permissions.json` (global) or `.agents/permissions.json` (project) to add bash rules; use `/mode` to switch modes - **pi-sem** — `npm install` should fetch the optional `@ataraxy-labs/sem` wrapper automatically; otherwise install `sem` globally with Homebrew or Cargo ## License -- 2.51.2 From 1a741f1586f2f5f8dc25e8c129fed8d732e247d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Tom=C5=A1ej?= Date: Fri, 17 Apr 2026 16:02:07 +0200 Subject: [PATCH 20/66] docs: fix leader-key shortcut (Ctrl+X) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1feace6..d5c402e 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Requires [Pi](https://github.com/badlogic/pi) v0.37.3+. ### [Leader Key](extensions/leader-key/) -Press `Ctrl+Space` to open a floating command palette — like Vim's which-key or Emacs' leader key. Actions are organized into single-character groups (`s` for Session, `m` for Model, `f` for Favourites, `t` for Thinking level, `l` for Labels). Auto-discovers extension commands and merges them with built-in actions. +Press `Ctrl+X` to open a floating command palette — like Vim's which-key or Emacs' leader key. Actions are organized into single-character groups (`s` for Session, `m` for Model, `f` for Favourites, `t` for Thinking level, `l` for Labels). Auto-discovers extension commands and merges them with built-in actions. Includes sub-modules: - **Model Switcher** — searchable provider → model → thinking level picker -- 2.51.2 From 704d71990641d5bd84f27bf1f6a1833806e463ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Koz=C3=A1k?= Date: Tue, 21 Apr 2026 22:12:57 +0200 Subject: [PATCH 21/66] fix: correct install instructions to use valid pi package source format The `github:` prefix is not a valid pi package source. Pi supports: - `git:github.com/user/repo` (git shorthand with git: prefix) - `https://github.com/user/repo` (full URL) Sub-path installs like `github:user/repo/extensions/foo` are also not supported. Added package filtering example for installing specific extensions or skills from the package. --- README.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d5c402e..2ad442a 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,31 @@ Extensions cover everything from UI polish (custom footer, tool pills, leader-ke ## Install ```bash -pi install github:tomsej/pi-ext +pi install git:github.com/tomsej/pi-ext ``` -Or install individual extensions: +Or using the full URL: ```bash -pi install github:tomsej/pi-ext/extensions/leader-key +pi install https://github.com/tomsej/pi-ext ``` +To install the full package but only load specific extensions or skills, use package filtering in your `settings.json`: + +```json +{ + "packages": [ + { + "source": "git:github.com/tomsej/pi-ext", + "extensions": ["extensions/leader-key"], + "skills": [] + } + ] +} +``` + +See [Pi Packages docs](https://github.com/badlogic/pi/blob/main/docs/packages.md) for more filtering options. + Requires [Pi](https://github.com/badlogic/pi) v0.37.3+. ## Extensions -- 2.51.2 From 1b1d7d0426e0860d857928e657d227e79225b031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:27:33 +0200 Subject: [PATCH 22/66] feat(superconductor): add Superconductor integration extension Footer pill with target branch + diff, superconductor_worktree tool (status, diff, target branch, list/create worktrees), and /sc-fork, /sc-worktree commands. Worktree create uses raw git since SC has no create API. Silent no-op outside Superconductor. --- README.md | 4 + extensions/superconductor/README.md | 52 ++++++++ extensions/superconductor/commands.ts | 104 +++++++++++++++ extensions/superconductor/git.ts | 145 ++++++++++++++++++++ extensions/superconductor/index.ts | 30 +++++ extensions/superconductor/sc-client.ts | 176 +++++++++++++++++++++++++ extensions/superconductor/status.ts | 60 +++++++++ extensions/superconductor/tools.ts | 129 ++++++++++++++++++ package.json | 1 + 9 files changed, 701 insertions(+) create mode 100644 extensions/superconductor/README.md create mode 100644 extensions/superconductor/commands.ts create mode 100644 extensions/superconductor/git.ts create mode 100644 extensions/superconductor/index.ts create mode 100644 extensions/superconductor/sc-client.ts create mode 100644 extensions/superconductor/status.ts create mode 100644 extensions/superconductor/tools.ts diff --git a/README.md b/README.md index 2ad442a..3696122 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,10 @@ Three-mode permission system: `yolo` (everything allowed), `safe` (rule-based ch Native integration with [cmux](https://github.com/badlogic/cmux). Context-aware notifications via the cmux socket API, sidebar status pills (model, state, thinking, tokens), and custom tools for the model (browser, workspace, notify). Silent no-op when not running inside cmux. +### [Superconductor](extensions/superconductor/) + +Native integration with [Superconductor](https://superconductor.dev) via the `sc` CLI. Footer pill with the Superconductor-owned target branch and diff size, a `superconductor_worktree` tool for the model (status, diff, target branch, list/create worktrees), and commands (`/sc-fork`, `/sc-worktree`). Silent no-op when not running inside Superconductor. + ### [Ask User Question](extensions/ask-user-question/) Registers an `ask_user_question` tool the model uses to ask 1–4 structured clarifying questions (with 2–4 options each) instead of asking in plain text. Interactive UI with optional multi-select and short header labels for a tab bar. diff --git a/extensions/superconductor/README.md b/extensions/superconductor/README.md new file mode 100644 index 0000000..d6cf8ff --- /dev/null +++ b/extensions/superconductor/README.md @@ -0,0 +1,52 @@ +# Superconductor + +Native [Superconductor](https://superconductor.dev) integration for pi. + +Superconductor launches pi inside managed git worktrees and terminal sessions, and exposes its state through the `sc` CLI (backed by a local API socket). This extension surfaces that integration directly inside pi so the agent — and you — can work with Superconductor without leaving the session. + +Inspired by the [cmux extension](../cmux/) and projects like [pi-cmux](https://github.com/javiermolinar/pi-cmux). Where cmux speaks a raw Unix-socket protocol, Superconductor is driven entirely through the `sc` CLI, so this extension shells out to `sc ... --json` and parses the structured envelope. + +If pi is not running inside Superconductor, the extension is a silent no-op. + +## What you get + +### Status pill + +A footer status pill shows the Superconductor-owned **target branch** and the current diff size: + +``` +SC →main · 6f +639 -0 +``` + +It refreshes on session start and after each agent run. This is additive — it never overwrites the Superconductor tab title. Set `PI_SC_TAB_TITLE=1` to also drive the tab title with a running/idle indicator. + +### Tools for the model + +| Tool | Actions | +|------|---------| +| `superconductor_worktree` | `status`, `diff_summary`, `set_target_branch`, `rename_branch`, `list_worktrees`, `create` | + +- **Worktree** lets the model read the authoritative target/base branch (it is Superconductor-owned — never inferred from git defaults), inspect the diff, list git worktrees, and **create new worktrees**. + + > **Creating worktrees.** Superconductor's local API (v17) exposes no command to create a managed worktree — SC builds those inside the app. So `create` (and the `/sc-worktree` command) fall back to **raw `git worktree add`** against the root repo (`SUPERCONDUCTOR_ROOT_PATH`), branching off `base` (default `main`). The result is a real git worktree on disk; with `open_in_sc` (the command always tries) it is surfaced via `sc workspace open`, but it is **not** registered as a Superconductor-managed task. + +### Commands + +- `/sc-fork [prompt]` — fork the current session into a new session file and open a fresh Superconductor terminal split. Superconductor terminal panes have no documented "send text" RPC, so the command opens the split and hands you a ready-to-paste `pi --session ` command. +- `/sc-worktree [base]` — create a git worktree off `base` (default `main`) on `branch`, then open it in Superconductor via `sc workspace open`. See the worktree-creation caveat above. + +## Configuration + +| Env var | Effect | +|---------|--------| +| `PI_SC_DISABLE=1` | Disable the extension entirely | +| `PI_SC_TAB_TITLE=1` | Drive the Superconductor tab title with running/idle state | +| `PI_SC_VERBOSE=1` | Log every `sc` invocation to stderr | +| `SUPERCONDUCTOR_SC_BIN` | Override the `sc` binary path (default: `sc` on PATH) | + +Detection uses `SUPERCONDUCTOR_MANAGED_AGENT=1` or the presence of `SUPERCONDUCTOR_LOCAL_API_SOCKET`. + +## Notes + +- All `sc` calls run with the worktree (`SUPERCONDUCTOR_WORKTREE_PATH`) as the working directory. +- `sc` envelopes come in two shapes: most commands wrap the payload in `response`, a few put it at the top level. Errors arrive as `{ "kind": "cli_error", "error": { "code", "message" } }`. The client normalizes all three. diff --git a/extensions/superconductor/commands.ts b/extensions/superconductor/commands.ts new file mode 100644 index 0000000..05b2dd8 --- /dev/null +++ b/extensions/superconductor/commands.ts @@ -0,0 +1,104 @@ +/** + * Superconductor slash commands. + * + * /sc-fork [prompt] — fork this pi session and open a new Superconductor + * terminal split. Superconductor terminal panes have no + * documented "send text" RPC, so we open the split and + * hand the user a ready-to-paste `pi --session` command. + * + * /sc-worktree NAME [base] — create a git worktree off base (default main) + * and open it in Superconductor via `sc workspace open`. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { existsSync, promises as fs } from "node:fs"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { ScClient } from "./sc-client.js"; +import { createWorktree } from "./git.js"; + +function shellQuote(value: string): string { + if (value.length === 0) return "''"; + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function piInvocation(): string[] { + const script = process.argv[1]; + if (script && existsSync(script)) return [process.execPath, script]; + const name = path.basename(process.execPath).toLowerCase(); + return /^(node|bun)(\.exe)?$/.test(name) ? ["pi"] : [process.execPath]; +} + +async function forkSessionFile(ctx: ExtensionCommandContext): Promise { + const sessionFile = ctx.sessionManager.getSessionFile(); + if (!sessionFile) return undefined; + + const dir = path.dirname(sessionFile); + const branch = ctx.sessionManager.getBranch(); + const header = ctx.sessionManager.getHeader(); + const ts = new Date().toISOString(); + const id = randomUUID(); + const newFile = path.join(dir, `${ts.replace(/[:.]/g, "-")}_${id}.jsonl`); + + const newHeader = { + type: "session", + version: header?.version ?? 3, + id, + timestamp: ts, + cwd: header?.cwd ?? ctx.cwd, + parentSession: sessionFile, + }; + const lines = [JSON.stringify(newHeader), ...branch.map((e) => JSON.stringify(e))].join("\n") + "\n"; + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(newFile, lines, "utf8"); + return newFile; +} + +export function wireCommands(pi: ExtensionAPI, client: ScClient): void { + pi.registerCommand("sc-fork", { + description: "Fork this session and open a new Superconductor terminal split. Usage: /sc-fork [prompt]", + handler: async (args, ctx) => { + const prompt = args.trim(); + const forked = await forkSessionFile(ctx); + + const split = await client.tabSplit("right", "terminal"); + if (!split.ok) { + ctx.ui.notify(`Could not open split: ${split.error?.message}`, "error"); + if (forked) ctx.ui.notify(`Forked session saved: ${forked}`, "info"); + return; + } + + if (!forked) { + ctx.ui.notify("Opened a terminal split (no persisted session to fork).", "warning"); + return; + } + + const parts = [...piInvocation(), "--session", forked]; + if (prompt) parts.push("--", prompt); + const cmd = parts.map(shellQuote).join(" "); + ctx.ui.notify(`Terminal split opened. Run in it:\n${cmd}`, "info"); + }, + }); + + pi.registerCommand("sc-worktree", { + description: "Create a new git worktree off a base branch. Usage: /sc-worktree [base] (base default: main)", + handler: async (args, ctx) => { + const [branch, base] = args.trim().split(/\s+/); + if (!branch) { + ctx.ui.notify("Usage: /sc-worktree [base]", "warning"); + return; + } + const res = await createWorktree({ branch, base }); + if (!res.ok) { + ctx.ui.notify(`Worktree create failed: ${res.error}`, "error"); + return; + } + const opened = await client.workspaceOpen(res.path); + const tail = opened.ok ? " (opened in SC)" : ` (open in SC failed: ${opened.error?.message})`; + ctx.ui.notify( + `Worktree ready: ${res.path}\nbranch ${res.branch} ← ${res.base}${tail}`, + opened.ok ? "info" : "warning", + ); + }, + }); +} diff --git a/extensions/superconductor/git.ts b/extensions/superconductor/git.ts new file mode 100644 index 0000000..d60b72d --- /dev/null +++ b/extensions/superconductor/git.ts @@ -0,0 +1,145 @@ +/** + * Git worktree helper. + * + * Superconductor's local API (v17) does NOT expose a "create worktree" + * command — its worktrees are created inside the app process and never + * surfaced over the `sc` CLI / socket. But SC worktrees are ultimately just + * `git worktree` checkouts, so we create one with raw git and (optionally) + * hand it to SC via `sc workspace open`. + * + * Everything here shells out to `git`. No throws on failure — we return a + * structured result the tool layer can present. + */ + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import * as path from "node:path"; + +export interface GitResult { + ok: boolean; + stdout: string; + stderr: string; + code: number | null; +} + +function git(repo: string, args: string[], timeout = 30_000): Promise { + return new Promise((resolve) => { + execFile("git", ["-C", repo, ...args], { timeout, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => { + const code = err ? ((err as NodeJS.ErrnoException).code === "ENOENT" ? null : (err as any).code ?? 1) : 0; + resolve({ + ok: !err, + stdout: (stdout || "").trim(), + stderr: (stderr || "").trim(), + code: typeof code === "number" ? code : null, + }); + }); + }); +} + +/** The primary checkout for the project (the repo SC branched this worktree from). */ +export function rootRepo(): string | undefined { + return process.env.SUPERCONDUCTOR_ROOT_PATH || undefined; +} + +function sanitizeBranchForPath(branch: string): string { + return branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "worktree"; +} + +async function branchExists(repo: string, branch: string): Promise { + const res = await git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]); + return res.ok && res.stdout.length > 0; +} + +export interface CreateWorktreeOptions { + /** New (or existing) branch name to check out in the worktree. */ + branch: string; + /** Base ref to branch from when creating a new branch. Default: "main". */ + base?: string; + /** Destination path. Default: sibling of the root repo named "-". */ + dest?: string; + /** Override the root repo. Default: SUPERCONDUCTOR_ROOT_PATH. */ + repo?: string; +} + +export interface CreateWorktreeResult { + ok: boolean; + /** Absolute path of the created worktree (set even on some failures for context). */ + path: string; + branch: string; + base: string; + /** Whether a new branch was created (vs. checking out an existing one). */ + createdBranch: boolean; + error?: string; +} + +/** + * Create a git worktree off `base` (default main) checked out on `branch`. + * + * - If the branch already exists, it is checked out (base is ignored). + * - If it does not exist, it is created from `base`. + */ +export async function createWorktree(opts: CreateWorktreeOptions): Promise { + const repo = opts.repo || rootRepo(); + const base = opts.base || "main"; + const branch = opts.branch; + + const fail = (path: string, createdBranch: boolean, error: string): CreateWorktreeResult => ({ + ok: false, + path, + branch, + base, + createdBranch, + error, + }); + + if (!repo) return fail("", false, "no root repo (SUPERCONDUCTOR_ROOT_PATH unset); pass repo explicitly"); + if (!existsSync(repo)) return fail("", false, `root repo does not exist: ${repo}`); + if (!branch || !branch.trim()) return fail("", false, "branch name is required"); + + // Resolve destination: explicit, else sibling of the repo. + const dest = opts.dest + ? path.resolve(opts.dest) + : path.join(path.dirname(repo), `${path.basename(repo)}-${sanitizeBranchForPath(branch)}`); + + if (existsSync(dest)) return fail(dest, false, `destination already exists: ${dest}`); + + const exists = await branchExists(repo, branch); + + // `git worktree add -b ` for a new branch, + // or `git worktree add ` to attach an existing one. + const args = exists + ? ["worktree", "add", dest, branch] + : ["worktree", "add", dest, "-b", branch, base]; + + const res = await git(repo, args); + if (!res.ok) { + return fail(dest, !exists, res.stderr || res.stdout || `git exited with code ${res.code}`); + } + + return { ok: true, path: dest, branch, base, createdBranch: !exists }; +} + +/** List existing git worktrees of the root repo (porcelain, parsed). */ +export async function listWorktrees(repo?: string): Promise< + { ok: boolean; worktrees: Array<{ path: string; branch?: string; head?: string }>; error?: string } +> { + const r = repo || rootRepo(); + if (!r) return { ok: false, worktrees: [], error: "no root repo (SUPERCONDUCTOR_ROOT_PATH unset)" }; + const res = await git(r, ["worktree", "list", "--porcelain"]); + if (!res.ok) return { ok: false, worktrees: [], error: res.stderr || res.stdout }; + + const worktrees: Array<{ path: string; branch?: string; head?: string }> = []; + let cur: { path: string; branch?: string; head?: string } | null = null; + for (const line of res.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + if (cur) worktrees.push(cur); + cur = { path: line.slice("worktree ".length) }; + } else if (line.startsWith("HEAD ") && cur) { + cur.head = line.slice("HEAD ".length); + } else if (line.startsWith("branch ") && cur) { + cur.branch = line.slice("branch ".length).replace(/^refs\/heads\//, ""); + } + } + if (cur) worktrees.push(cur); + return { ok: true, worktrees }; +} diff --git a/extensions/superconductor/index.ts b/extensions/superconductor/index.ts new file mode 100644 index 0000000..138d6e6 --- /dev/null +++ b/extensions/superconductor/index.ts @@ -0,0 +1,30 @@ +/** + * pi-superconductor: native Superconductor integration for pi. + * + * Superconductor launches pi inside managed worktrees + terminal sessions and + * exposes everything through the `sc` CLI (backed by the local API socket). + * This extension surfaces that to pi: + * + * - Status: footer pill with target branch + diff size (+ optional tab title) + * - Tools: superconductor_worktree (status/diff/target branch + create worktree) + * - Commands: /sc-fork (fork into a new terminal split), /sc-worktree (new worktree) + * + * Gracefully degrades: if not running inside Superconductor, it is a no-op. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { ScClient } from "./sc-client.js"; +import { wireStatus } from "./status.js"; +import { wireTools } from "./tools.js"; +import { wireCommands } from "./commands.js"; + +export default function (pi: ExtensionAPI) { + const client = new ScClient(); + + // Not inside Superconductor — do nothing. + if (!client.available) return; + + wireStatus(pi, client); + wireTools(pi, client); + wireCommands(pi, client); +} diff --git a/extensions/superconductor/sc-client.ts b/extensions/superconductor/sc-client.ts new file mode 100644 index 0000000..ff49085 --- /dev/null +++ b/extensions/superconductor/sc-client.ts @@ -0,0 +1,176 @@ +/** + * Superconductor CLI client — thin wrapper around the `sc` binary. + * + * Unlike cmux (which speaks a raw Unix-socket protocol), Superconductor + * exposes everything through the `sc` CLI, which in turn talks to the + * local API socket (SUPERCONDUCTOR_LOCAL_API_SOCKET). We shell out to `sc` + * with `--json` / `--output json` and parse the structured envelope. + * + * Response envelope shapes observed in the wild: + * { "kind": "worktree_status", "response": { ... } } // most commands + * { "kind": "providers", "providers": [ ... ] } // some commands put data at top level + * { "kind": "cli_error", "error": { "code", "message" } } // any failure + * + * Gracefully degrades: if not running inside Superconductor (or `sc` is + * missing), `available` is false and every call resolves to a failed + * ScResult. No throws, no noise. + */ + +import { execFile } from "node:child_process"; + +export interface ScError { + code: string; + message: string; +} + +export interface ScResult { + /** True when the command ran and `sc` did not return a cli_error. */ + ok: boolean; + /** The "kind" discriminator from the envelope, or "exec_error". */ + kind: string; + /** The payload: `response` field when present, otherwise the whole envelope. */ + response: T | null; + /** Populated when ok is false. */ + error: ScError | null; + /** Full parsed envelope, for callers that need top-level fields. */ + raw: any; +} + +function fail(kind: string, error: ScError): ScResult { + return { ok: false, kind, response: null, error, raw: null }; +} + +export class ScClient { + private readonly bin: string; + private readonly worktree: string | undefined; + private readonly verbose: boolean; + + constructor() { + this.bin = process.env.SUPERCONDUCTOR_SC_BIN || "sc"; + this.worktree = process.env.SUPERCONDUCTOR_WORKTREE_PATH; + this.verbose = process.env.PI_SC_VERBOSE === "1"; + } + + /** True if we appear to be running as a Superconductor-managed agent. */ + get available(): boolean { + if (process.env.PI_SC_DISABLE === "1") return false; + return ( + process.env.SUPERCONDUCTOR_MANAGED_AGENT === "1" || + !!process.env.SUPERCONDUCTOR_LOCAL_API_SOCKET + ); + } + + /** + * Run an `sc` subcommand. `args` should NOT include the json flag — the + * caller picks `--json` or `--output json` via `outputFlag`, because the + * CLI is inconsistent across command families. + */ + async run( + args: string[], + opts: { stdin?: string; timeout?: number; outputFlag?: "json" | "output-json" | "none" } = {}, + ): Promise { + if (!this.available) { + return fail("unavailable", { code: "unavailable", message: "Superconductor is not available" }); + } + + const flagArgs = + opts.outputFlag === "output-json" + ? ["--output", "json"] + : opts.outputFlag === "none" + ? [] + : ["--json"]; + const fullArgs = [...args, ...flagArgs]; + const timeout = opts.timeout ?? 10_000; + + if (this.verbose) console.error("[pi-sc] ->", this.bin, fullArgs.join(" ")); + + return new Promise((resolve) => { + const child = execFile( + this.bin, + fullArgs, + { timeout, cwd: this.worktree, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout, stderr) => { + const out = (stdout || "").trim(); + + // Try to parse JSON first — even on a non-zero exit, `sc` usually + // prints a structured cli_error envelope to stdout. + const parsed = this.parse(out); + if (parsed) { + if (this.verbose) console.error("[pi-sc] <-", parsed.kind, parsed.ok ? "ok" : parsed.error?.code); + return resolve(parsed); + } + + if (err) { + const msg = (stderr || "").trim() || err.message; + const code = (err as NodeJS.ErrnoException).code === "ENOENT" ? "sc_not_found" : "exec_error"; + return resolve(fail("exec_error", { code, message: msg })); + } + + resolve(fail("parse_error", { code: "parse_error", message: out || "empty output" })); + }, + ); + + if (opts.stdin !== undefined) { + child.stdin?.write(opts.stdin); + child.stdin?.end(); + } + }); + } + + /** Parse a single-line JSON envelope into an ScResult, or null if not JSON. */ + private parse(out: string): ScResult | null { + if (!out) return null; + // `sc` may emit several JSON lines (e.g. watch). We only want the last + // complete object for one-shot commands. + const line = out.split("\n").reverse().find((l) => l.trim().startsWith("{")); + if (!line) return null; + let obj: any; + try { + obj = JSON.parse(line); + } catch { + return null; + } + const kind = typeof obj.kind === "string" ? obj.kind : "unknown"; + if (kind === "cli_error" || obj.error) { + const e = obj.error ?? {}; + return fail(kind, { code: e.code ?? "error", message: e.message ?? "unknown error" }); + } + const { kind: _k, response, ...rest } = obj; + return { ok: true, kind, response: response ?? rest, error: null, raw: obj }; + } + + // ── Worktree ────────────────────────────────────────────────────────── + + worktreeStatus(): Promise { + return this.run(["worktree", "status"]); + } + + diffSummary(file?: string): Promise { + return this.run(["worktree", "diff-summary", ...(file ? ["--file", file] : [])]); + } + + setTargetBranch(branch: string): Promise { + return this.run(["worktree", "set-target-branch", branch]); + } + + renameBranch(name: string): Promise { + return this.run(["worktree", "rename-branch", name]); + } + + // ── Workspace ───────────────────────────────────────────────────────── + + /** Open a directory as a Superconductor workspace (project). */ + workspaceOpen(path: string, activate = true): Promise { + return this.run(["workspace", "open", path, ...(activate ? ["--activate"] : [])]); + } + + // ── Tab ─────────────────────────────────────────────────────────────── + + tabTitle(title: string): Promise { + return this.run(["tab", "title", title]); + } + + tabSplit(direction: "up" | "down" | "left" | "right", ui: "auto" | "chat" | "terminal" = "terminal"): Promise { + return this.run(["tab", "split", "--direction", direction, "--ui", ui]); + } +} diff --git a/extensions/superconductor/status.ts b/extensions/superconductor/status.ts new file mode 100644 index 0000000..e0dccd2 --- /dev/null +++ b/extensions/superconductor/status.ts @@ -0,0 +1,60 @@ +/** + * Superconductor status surface. + * + * Two non-destructive signals: + * 1. A pi footer status pill ("SC →main · +12 -3") showing the target + * branch and current diff size. Refreshed on session start and after + * each agent run. This is purely additive — it does not touch the + * Superconductor tab title. + * 2. Optional tab-title state (opt-in via PI_SC_TAB_TITLE=1). Because the + * tab title is user-facing and shared, we only drive it when asked. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import type { ScClient } from "./sc-client.js"; + +const STATUS_KEY = "superconductor"; + +interface WorktreeStatus { + branch?: string; + target_branch?: string; + files_changed?: number; + insertions?: number; + deletions?: number; +} + +export function wireStatus(pi: ExtensionAPI, client: ScClient): void { + const driveTabTitle = process.env.PI_SC_TAB_TITLE === "1"; + let baseLabel = "SC"; + + async function refreshFooter(ctx: { ui: { setStatus(key: string, value?: string): void; hasUI?: boolean } }): Promise { + const res = await client.worktreeStatus(); + if (!res.ok) return; + const s = res.response as WorktreeStatus; + const target = s.target_branch ? `→${s.target_branch}` : ""; + const diff = + s.files_changed && s.files_changed > 0 + ? ` · ${s.files_changed}f +${s.insertions ?? 0} -${s.deletions ?? 0}` + : ""; + baseLabel = `SC ${target}`.trim(); + ctx.ui.setStatus(STATUS_KEY, `${baseLabel}${diff}`); + } + + pi.on("session_start", async (_event, ctx) => { + await refreshFooter(ctx as any); + if (driveTabTitle) await client.tabTitle("● pi idle"); + }); + + pi.on("agent_start", async () => { + if (driveTabTitle) await client.tabTitle("⚡ pi running"); + }); + + pi.on("agent_end", async (_event, ctx) => { + await refreshFooter(ctx as any); + if (driveTabTitle) await client.tabTitle("✓ pi idle"); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + (ctx as any)?.ui?.setStatus?.(STATUS_KEY, undefined); + }); +} diff --git a/extensions/superconductor/tools.ts b/extensions/superconductor/tools.ts new file mode 100644 index 0000000..56a4875 --- /dev/null +++ b/extensions/superconductor/tools.ts @@ -0,0 +1,129 @@ +/** + * Custom tools exposing Superconductor to the LLM. + * + * - superconductor_worktree: read worktree status / diff, change target branch, + * list git worktrees, and create new ones (raw git, since SC has no create API). + * + * The tool surfaces `sc` cli_error envelopes verbatim so the model can react. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { truncateTail } from "@mariozechner/pi-coding-agent"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; +import type { ScClient, ScResult } from "./sc-client.js"; +import { createWorktree, listWorktrees, rootRepo } from "./git.js"; + +type ToolResult = { content: Array<{ type: "text"; text: string }>; details: Record }; + +function text(s: string, details: Record = {}): ToolResult { + return { content: [{ type: "text", text: s }], details }; +} + +/** Render an ScResult as a tool result. Throws on failure so pi flags isError. */ +function present(res: ScResult, emptyNote = "ok"): ToolResult { + if (!res.ok) { + throw new Error(`sc error [${res.error?.code}]: ${res.error?.message}`); + } + const payload = res.response; + if (payload === null || payload === undefined) return text(emptyNote); + const json = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2); + const truncated = truncateTail(json, { maxBytes: 50_000, maxLines: 2000 }); + return text(truncated.content, { kind: res.kind }); +} + +export function wireTools(pi: ExtensionAPI, client: ScClient): void { + // ── superconductor_worktree ───────────────────────────────────────────── + pi.registerTool({ + name: "superconductor_worktree", + label: "SC Worktree", + description: [ + "Inspect and control the Superconductor worktree.", + "", + "status — branch, target/base branch, files changed, +/- lines", + "diff_summary — per-file change summary (optional file filter)", + "set_target_branch — change the review/merge target branch", + "rename_branch — rename the current worktree branch", + "list_worktrees — list git worktrees of the root repo", + "create — create a NEW git worktree off a base branch (default main),", + " checked out on a new/existing branch; optionally open it in SC.", + "", + "The target/base branch is Superconductor-owned. Use status to read it; never infer from git defaults.", + "Note: SC has no API to create a managed worktree, so create uses raw git. The new worktree", + "is a real git checkout; with open_in_sc it is surfaced via `sc workspace open` but is not a", + "Superconductor-managed task.", + ].join("\n"), + promptSnippet: "Read Superconductor worktree status, diff, target branch; create new git worktrees off main.", + promptGuidelines: [ + "Use superconductor_worktree status to read the authoritative target/base branch instead of guessing from git.", + "To branch off main into a fresh worktree, use action=create with a branch name (base defaults to main).", + ], + parameters: Type.Object({ + action: StringEnum([ + "status", + "diff_summary", + "set_target_branch", + "rename_branch", + "list_worktrees", + "create", + ] as const), + file: Type.Optional(Type.String({ description: "For diff_summary: limit to a single file path" })), + branch: Type.Optional( + Type.String({ description: "For set_target_branch: new target branch. For create: new/existing branch to check out." }), + ), + name: Type.Optional(Type.String({ description: "For rename_branch: the new branch name" })), + base: Type.Optional(Type.String({ description: "For create: base ref to branch from (default: main)" })), + path: Type.Optional( + Type.String({ description: "For create: destination dir (default: sibling of root repo, '-')" }), + ), + open_in_sc: Type.Optional( + Type.Boolean({ description: "For create: open the new worktree in Superconductor via `sc workspace open`" }), + ), + }), + async execute(_id, params): Promise { + switch (params.action) { + case "status": + return present(await client.worktreeStatus()); + case "diff_summary": + return present(await client.diffSummary(params.file)); + case "set_target_branch": + if (!params.branch) throw new Error("branch is required for set_target_branch"); + return present(await client.setTargetBranch(params.branch), "target branch updated"); + case "rename_branch": + if (!params.name) throw new Error("name is required for rename_branch"); + return present(await client.renameBranch(params.name), "branch renamed"); + case "list_worktrees": { + const res = await listWorktrees(); + if (!res.ok) throw new Error(`git error: ${res.error}`); + return text(JSON.stringify({ root: rootRepo(), worktrees: res.worktrees }, null, 2)); + } + case "create": { + if (!params.branch) throw new Error("branch is required for create"); + const created = await createWorktree({ + branch: params.branch, + base: params.base, + dest: params.path, + }); + if (!created.ok) throw new Error(`worktree create failed: ${created.error}`); + let opened: ScResult | null = null; + if (params.open_in_sc) opened = await client.workspaceOpen(created.path); + return text( + JSON.stringify( + { + path: created.path, + branch: created.branch, + base: created.base, + created_branch: created.createdBranch, + opened_in_sc: opened ? opened.ok : false, + open_error: opened && !opened.ok ? opened.error?.message : undefined, + }, + null, + 2, + ), + { kind: "worktree_created" }, + ); + } + } + }, + }); +} diff --git a/package.json b/package.json index ee30fb7..ecd3a1b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "./extensions/ask-user-question/index.ts", "./extensions/tool-pills/index.ts", "./extensions/cmux/index.ts", + "./extensions/superconductor/index.ts", "./extensions/session-snap/index.ts", "./extensions/pi-telescope/index.ts", "./extensions/handoff/index.ts", -- 2.51.2 From 0efc362304f8305d29f491f5fbdd8101ec3f20ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:30:58 +0200 Subject: [PATCH 23/66] feat(muxy-notify): add notification extension --- extensions/muxy-notify/index.test.ts | 184 ++++++++++++++++++++++++++ extensions/muxy-notify/index.ts | 104 +++++++++++++++ extensions/muxy-notify/smoke-test.mjs | 71 ++++++++++ 3 files changed, 359 insertions(+) create mode 100644 extensions/muxy-notify/index.test.ts create mode 100644 extensions/muxy-notify/index.ts create mode 100644 extensions/muxy-notify/smoke-test.mjs diff --git a/extensions/muxy-notify/index.test.ts b/extensions/muxy-notify/index.test.ts new file mode 100644 index 0000000..fb0bad7 --- /dev/null +++ b/extensions/muxy-notify/index.test.ts @@ -0,0 +1,184 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { createServer, type Server } from "node:net"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// We need to test the module behavior, so we'll dynamically import it fresh each test +let mockSocketPath: string; +let mockServer: Server; +let receivedMessages: string[]; + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function setupMockMuxyServer(): Promise { + return new Promise((resolve) => { + receivedMessages = []; + mockServer = createServer((socket) => { + let data = ""; + socket.on("data", (chunk) => { + data += chunk.toString(); + }); + socket.on("end", () => { + receivedMessages.push(data); + }); + }); + mockServer.listen(mockSocketPath, () => resolve()); + }); +} + +async function loadExtension(): Promise { + // Dynamic import to get fresh module each time + const mod = await import("./index.ts"); + return mod.default; +} + +function createMockPi(): { + listeners: Record void>>; + emit: (event: string, data: any) => Promise; + on: (event: string, handler: (event: any) => void) => void; +} { + const listeners: Record void>> = {}; + return { + listeners, + on: (event: string, handler: (event: any) => void) => { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(handler); + }, + emit: async (event: string, data: any) => { + const handlers = listeners[event] || []; + for (const h of handlers) { + await h(data); + } + }, + }; +} + +describe("muxy-notify", () => { + beforeEach(() => { + const tmpDir = join(tmpdir(), `muxy-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + mockSocketPath = join(tmpDir, "muxy.sock"); + receivedMessages = []; + }); + + afterEach(() => { + if (mockServer) { + mockServer.close(); + } + try { + rmSync(join(tmpdir(), mockSocketPath.split("/").slice(-2, -1)[0]), { recursive: true }); + } catch {} + }); + + test("should send notification on agent_end", async () => { + process.env.MUXY_SOCKET_PATH = mockSocketPath; + process.env.MUXY_PANE_ID = "test-pane-123"; + + await setupMockMuxyServer(); + const ext = await loadExtension(); + const pi = createMockPi(); + ext(pi as any); + + await pi.emit("before_agent_start", { prompt: "refactor auth" }); + await pi.emit("agent_end", { messages: [] }); + await wait(200); + + expect(receivedMessages.length).toBe(1); + expect(receivedMessages[0]).toContain("pi|test-pane-123|"); + expect(receivedMessages[0]).toContain("Done: refactor auth"); + }); + + test("should debounce rapid agent_end calls", async () => { + process.env.MUXY_SOCKET_PATH = mockSocketPath; + process.env.MUXY_PANE_ID = "test-pane-456"; + + await setupMockMuxyServer(); + const ext = await loadExtension(); + const pi = createMockPi(); + ext(pi as any); + + await pi.emit("before_agent_start", { prompt: "task 1" }); + await pi.emit("agent_end", { messages: [] }); + await pi.emit("before_agent_start", { prompt: "task 2" }); + await pi.emit("agent_end", { messages: [] }); + await wait(200); + + // Only first agent_end should send, second debounced + expect(receivedMessages.length).toBe(1); + expect(receivedMessages[0]).toContain("task 1"); + }); + + test("should reset currentPrompt on new before_agent_start", async () => { + process.env.MUXY_SOCKET_PATH = mockSocketPath; + process.env.MUXY_PANE_ID = "test-pane-789"; + + await setupMockMuxyServer(); + const ext = await loadExtension(); + const pi = createMockPi(); + ext(pi as any); + + await pi.emit("before_agent_start", { prompt: "first task" }); + await pi.emit("before_agent_start", { prompt: "second task" }); + await wait(600); // wait for debounce window + await pi.emit("agent_end", { messages: [] }); + await wait(200); + + expect(receivedMessages.length).toBe(1); + expect(receivedMessages[0]).toContain("second task"); + expect(receivedMessages[0]).not.toContain("first task"); + }); + + test("should fallback to assistant text when no prompt", async () => { + process.env.MUXY_SOCKET_PATH = mockSocketPath; + process.env.MUXY_PANE_ID = "test-pane-abc"; + + await setupMockMuxyServer(); + const ext = await loadExtension(); + const pi = createMockPi(); + ext(pi as any); + + await pi.emit("agent_end", { + messages: [ + { role: "assistant", content: [{ type: "text", text: "All done here" }] }, + ], + }); + await wait(200); + + expect(receivedMessages.length).toBe(1); + expect(receivedMessages[0]).toContain("All done here"); + }); + + test("should send ask_user_question notification", async () => { + process.env.MUXY_SOCKET_PATH = mockSocketPath; + process.env.MUXY_PANE_ID = "test-pane-def"; + + await setupMockMuxyServer(); + const ext = await loadExtension(); + const pi = createMockPi(); + ext(pi as any); + + await pi.emit("tool_call", { toolName: "ask_user_question" }); + await wait(200); + + expect(receivedMessages.length).toBe(1); + expect(receivedMessages[0]).toContain("Needs input"); + }); + + test("should not send when env vars missing", async () => { + delete process.env.MUXY_SOCKET_PATH; + delete process.env.MUXY_PANE_ID; + + await setupMockMuxyServer(); + const ext = await loadExtension(); + const pi = createMockPi(); + ext(pi as any); + + await pi.emit("agent_end", { messages: [] }); + await wait(200); + + expect(receivedMessages.length).toBe(0); + }); +}); diff --git a/extensions/muxy-notify/index.ts b/extensions/muxy-notify/index.ts new file mode 100644 index 0000000..b8db87f --- /dev/null +++ b/extensions/muxy-notify/index.ts @@ -0,0 +1,104 @@ +import type { AssistantMessage, Message, TextContent } from "@mariozechner/pi-ai"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { createConnection } from "node:net"; +import { basename } from "node:path"; + +const socketPath = process.env.MUXY_SOCKET_PATH; +const paneID = process.env.MUXY_PANE_ID; +const enabled = Boolean(socketPath && paneID); + +function clean(text: string, max = 200): string { + const normalized = text.trim().replace(/[\n\r|]+/g, " ").replace(/\s+/g, " "); + if (normalized.length <= max) return normalized; + return normalized.slice(0, max - 3) + "..."; +} + +function sendToMuxy(type: string, title: string, body: string): void { + if (!enabled || !socketPath || !paneID) return; + + const payload = `${type}|${paneID}|${clean(title, 80)}|${clean(body, 200)}`; + const conn = createConnection({ path: socketPath }); + + let done = false; + const timeout = setTimeout(() => { + if (!done) { + done = true; + conn.destroy(); + } + }, 3000); + + conn.on("error", () => { + clearTimeout(timeout); + conn.destroy(); + }); + + conn.on("connect", () => { + conn.write(payload, (err) => { + clearTimeout(timeout); + if (!done) { + done = true; + if (err) conn.destroy(); + else conn.end(); + } + }); + }); + + conn.on("close", () => { + clearTimeout(timeout); + done = true; + }); +} + +function lastAssistantText(messages: Message[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== "assistant") continue; + + const text = (msg as AssistantMessage).content + .filter((content): content is TextContent => content.type === "text") + .map((content) => content.text) + .join(" ") + .trim(); + + if (text) return clean(text, 160); + } + + return null; +} + +export default function (pi: ExtensionAPI) { + if (!enabled) return; + + let currentPrompt: string | null = null; + let lastNotifyTime = 0; + const notifyDebounceMs = 500; + + pi.on("before_agent_start", async (event) => { + currentPrompt = null; + if (typeof event.prompt === "string" && event.prompt.trim()) { + currentPrompt = event.prompt.trim(); + } + }); + + pi.on("tool_call", async (event) => { + if (event.toolName !== "ask_user_question") return; + sendToMuxy("pi", `Pi · ${basename(process.cwd())}`, "Needs input"); + }); + + pi.on("agent_end", async (event) => { + const now = Date.now(); + if (now - lastNotifyTime < notifyDebounceMs) { + currentPrompt = null; + return; + } + lastNotifyTime = now; + + const title = `Pi · ${basename(process.cwd()) || "session"}`; + const body = currentPrompt + ? `Done: ${clean(currentPrompt, 150)}` + : (lastAssistantText(event.messages as Message[]) ?? "Task completed"); + + sendToMuxy("pi", title, body); + currentPrompt = null; + }); +} diff --git a/extensions/muxy-notify/smoke-test.mjs b/extensions/muxy-notify/smoke-test.mjs new file mode 100644 index 0000000..bb4b3a4 --- /dev/null +++ b/extensions/muxy-notify/smoke-test.mjs @@ -0,0 +1,71 @@ +import { createServer } from "node:net"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const tmpDir = mkdtempSync(join(tmpdir(), "muxy-smoke-")); +const socketPath = join(tmpDir, "muxy.sock"); + +const messages = []; +const server = createServer((socket) => { + let data = ""; + socket.on("data", (chunk) => { data += chunk.toString(); }); + socket.on("end", () => { messages.push(data); }); +}); + +await new Promise((resolve) => server.listen(socketPath, resolve)); +console.log("Mock server listening on", socketPath); + +// Set env and import extension +process.env.MUXY_SOCKET_PATH = socketPath; +process.env.MUXY_PANE_ID = "smoke-pane-42"; + +const mod = await import("./index.ts"); +const ext = mod.default; + +const listeners = {}; +const pi = { + on: (event, handler) => { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(handler); + }, +}; +ext(pi); + +// Test 1: agent_end with prompt +await listeners["before_agent_start"]?.[0]?.({ prompt: "refactor auth" }); +await listeners["agent_end"]?.[0]?.({ messages: [] }); +await new Promise((r) => setTimeout(r, 300)); + +console.log("Messages after test 1:", messages.length); +console.log("Content:", messages[0] || "NONE"); +console.assert(messages.length === 1, "Expected 1 message"); +console.assert(messages[0]?.includes("Done: refactor auth"), "Expected prompt in body"); + +// Test 2: debounce rapid agent_end (wait for debounce window to clear first) +messages.length = 0; +await new Promise((r) => setTimeout(r, 600)); // clear debounce from test 1 +await listeners["before_agent_start"]?.[0]?.({ prompt: "task 1" }); +await listeners["agent_end"]?.[0]?.({ messages: [] }); +await listeners["before_agent_start"]?.[0]?.({ prompt: "task 2" }); +await listeners["agent_end"]?.[0]?.({ messages: [] }); +await new Promise((r) => setTimeout(r, 300)); +console.log("Messages after test 2 (debounce):", messages.length); +console.assert(messages.length === 1, "Expected 1 message after debounce"); + +// Test 3: reset prompt on new before_agent_start +messages.length = 0; +await listeners["before_agent_start"]?.[0]?.({ prompt: "first" }); +await listeners["before_agent_start"]?.[0]?.({ prompt: "second" }); +await new Promise((r) => setTimeout(r, 600)); // wait for debounce window +await listeners["agent_end"]?.[0]?.({ messages: [] }); +await new Promise((r) => setTimeout(r, 300)); +console.log("Messages after test 3 (reset):", messages.length); +console.log("Content:", messages[0] || "NONE"); +console.assert(messages.length === 1, "Expected 1 message"); +console.assert(messages[0]?.includes("second"), "Expected 'second' prompt"); +console.assert(!messages[0]?.includes("first"), "Should NOT contain 'first' prompt"); + +server.close(); +rmSync(tmpDir, { recursive: true }); +console.log("\nAll smoke tests passed!"); -- 2.51.2 From 49d0ce03e328459532c198137d149d046d5c6b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:30:58 +0200 Subject: [PATCH 24/66] feat(session-store): add session store extension --- extensions/session-store/db.ts | 449 +++++++++++++++++++++ extensions/session-store/index.ts | 301 ++++++++++++++ extensions/session-store/search-overlay.ts | 435 ++++++++++++++++++++ 3 files changed, 1185 insertions(+) create mode 100644 extensions/session-store/db.ts create mode 100644 extensions/session-store/index.ts create mode 100644 extensions/session-store/search-overlay.ts diff --git a/extensions/session-store/db.ts b/extensions/session-store/db.ts new file mode 100644 index 0000000..6c556ca --- /dev/null +++ b/extensions/session-store/db.ts @@ -0,0 +1,449 @@ +/** + * Database layer for session-store + * + * SQLite + FTS5 for full-text search across all pi sessions. + * BM25 ranking via FTS5 built-in rank column. + */ + +import { DatabaseSync } from "node:sqlite"; +import { existsSync, readFileSync, statSync, readdirSync } from "node:fs"; +import { join, basename } from "node:path"; + +const DB_PATH = `${process.env.HOME}/.pi/agent/session-store.db`; +const SESSIONS_DIR = `${process.env.HOME}/.pi/agent/sessions`; + +let _db: DatabaseSync | null = null; +let _ftsAvailable = false; + +export interface SessionRecord { + path: string; + id: string; + cwd: string; + name: string | null; + created_at: number; + last_activity_at: number; + user_message_count: number; + assistant_message_count: number; + total_entries: number; + file_size_bytes: number; + file_mtime: number; + indexed_at: number; + search_text: string; +} + +export interface SearchResult { + record: SessionRecord; + rank: number; +} + +export interface IndexStats { + totalSessions: number; + dbSizeBytes: number; +} + +export interface IndexedSessionCursor { + limit?: number; + offset?: number; +} + +/** Get or create database connection */ +function getDb(): DatabaseSync { + if (_db) return _db; + + _db = new DatabaseSync(DB_PATH); + _db.exec("PRAGMA journal_mode=WAL"); + + // Create metadata table + _db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + path TEXT PRIMARY KEY, + id TEXT, + cwd TEXT, + name TEXT, + created_at INTEGER, + last_activity_at INTEGER, + user_message_count INTEGER, + assistant_message_count INTEGER, + total_entries INTEGER, + file_size_bytes INTEGER, + file_mtime INTEGER, + indexed_at INTEGER, + search_text TEXT + ) + `); + + // Migration: add file_mtime if missing (v1 schema didn't have it) + try { + _db.prepare("SELECT file_mtime FROM sessions LIMIT 1").get(); + } catch { + _db.exec("ALTER TABLE sessions ADD COLUMN file_mtime INTEGER"); + } + + // Create FTS5 virtual table for full-text search + try { + _db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5( + search_text, name, cwd, + content='sessions', + content_rowid='rowid' + ) + `); + + // Trigger: keep FTS in sync on insert + _db.exec(` + CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN + INSERT INTO sessions_fts(rowid, search_text, name, cwd) + VALUES (new.rowid, new.search_text, new.name, new.cwd); + END + `); + + // Trigger: keep FTS in sync on delete + _db.exec(` + CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN + INSERT INTO sessions_fts(sessions_fts, rowid, search_text, name, cwd) + VALUES ('delete', old.rowid, old.search_text, old.name, old.cwd); + END + `); + + // Existing databases may predate triggers or may have stale FTS rows. + // Rebuild when counts drift so Telescope/search use the stored index reliably. + try { + const sessionCount = (_db.prepare("SELECT COUNT(*) AS count FROM sessions").get() as any)?.count ?? 0; + const ftsCount = (_db.prepare("SELECT COUNT(*) AS count FROM sessions_fts").get() as any)?.count ?? 0; + if (sessionCount !== ftsCount) { + _db.exec("INSERT INTO sessions_fts(sessions_fts) VALUES('rebuild')"); + } + } catch { + _db.exec("INSERT INTO sessions_fts(sessions_fts) VALUES('rebuild')"); + } + + _ftsAvailable = true; + } catch (err) { + console.error("[session-store] FTS5 not available:", err); + _ftsAvailable = false; + } + + return _db; +} + +/** Check if FTS5 is available */ +export function isFtsAvailable(): boolean { + getDb(); + return _ftsAvailable; +} + +/** Extract text content from a message content field */ +function extractMessageText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join("\n"); + } + return ""; +} + +/** Parse a JSONL session file and return record data */ +function parseSession(filePath: string): SessionRecord | null { + let raw: string; + try { + raw = readFileSync(filePath, "utf-8"); + } catch { + return null; + } + + const stat = statSync(filePath); + const lines = raw.split("\n"); + + let id = ""; + let cwd = ""; + let name: string | null = null; + let createdAt = 0; + let lastActivityAt = 0; + let userMessageCount = 0; + let assistantMessageCount = 0; + const texts: string[] = []; + + for (const line of lines) { + if (!line.trim()) continue; + + try { + const entry = JSON.parse(line); + const ts = parseTimestamp(entry.timestamp); + + if (ts > 0) { + if (createdAt === 0 || ts < createdAt) createdAt = ts; + if (ts > lastActivityAt) lastActivityAt = ts; + } + + if (entry.type === "session") { + id = entry.id || ""; + cwd = entry.cwd || ""; + } else if (entry.type === "session_info" && entry.name) { + name = entry.name; + } else if (entry.type === "message" && entry.message) { + const msg = entry.message; + if (msg.role === "user" || msg.role === "assistant") { + const text = extractMessageText(msg.content); + if (text.trim()) { + texts.push(text.trim()); + if (msg.role === "user") userMessageCount++; + else assistantMessageCount++; + } + } + } + } catch { + // Skip malformed lines + } + } + + return { + path: filePath, + id, + cwd, + name, + created_at: createdAt, + last_activity_at: lastActivityAt, + user_message_count: userMessageCount, + assistant_message_count: assistantMessageCount, + total_entries: lines.filter((l) => l.trim()).length, + file_size_bytes: stat.size, + file_mtime: stat.mtimeMs, + indexed_at: Date.now(), + search_text: texts.join("\n\n"), + }; +} + +function parseTimestamp(ts: unknown): number { + if (typeof ts === "number") return ts; + if (typeof ts === "string") return new Date(ts).getTime(); + return 0; +} + +/** Index a single session file. Re-indexes if already present. */ +export function indexSession(filePath: string): void { + const db = getDb(); + const record = parseSession(filePath); + if (!record) return; + + db.exec("BEGIN"); + try { + db.prepare("DELETE FROM sessions WHERE path = ?").run(filePath); + db.prepare(` + INSERT INTO sessions + (path, id, cwd, name, created_at, last_activity_at, + user_message_count, assistant_message_count, total_entries, + file_size_bytes, file_mtime, indexed_at, search_text) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + record.path, + record.id, + record.cwd, + record.name, + record.created_at, + record.last_activity_at, + record.user_message_count, + record.assistant_message_count, + record.total_entries, + record.file_size_bytes, + record.file_mtime, + record.indexed_at, + record.search_text, + ); + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } +} + +/** Get all indexed session paths with their total entry counts */ +export function getIndexedSessions(): Map { + const db = getDb(); + const rows = db.prepare("SELECT path, total_entries FROM sessions").all() as any[]; + const map = new Map(); + for (const row of rows) { + map.set(row.path, row.total_entries); + } + return map; +} + +/** List indexed sessions, newest activity first. Used by Telescope for fast browsing. */ +export function listIndexedSessions(cursor: IndexedSessionCursor = {}): SessionRecord[] { + const db = getDb(); + const limit = Math.max(1, Math.min(cursor.limit ?? 5000, 20_000)); + const offset = Math.max(0, cursor.offset ?? 0); + const rows = db.prepare(` + SELECT * FROM sessions + WHERE (user_message_count + assistant_message_count) > 0 + ORDER BY last_activity_at DESC, file_mtime DESC + LIMIT ? OFFSET ? + `).all(limit, offset) as any[]; + return rows.map(rowToRecord); +} + +/** Delete missing files from the index so stale deleted sessions do not show up. */ +export function pruneMissingSessions(existingPaths?: Set): number { + const db = getDb(); + const rows = db.prepare("SELECT path FROM sessions").all() as any[]; + let removed = 0; + + db.exec("BEGIN"); + try { + const stmt = db.prepare("DELETE FROM sessions WHERE path = ?"); + for (const row of rows) { + const path = row.path as string; + const exists = existingPaths ? existingPaths.has(path) : existsSync(path); + if (!exists) { + stmt.run(path); + removed++; + } + } + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } + + return removed; +} + +/** Get all indexed sessions with file mtime for fast catch-up */ +export function getIndexedSessionsWithMtime(): Map { + const db = getDb(); + const rows = db.prepare("SELECT path, total_entries, file_mtime FROM sessions").all() as any[]; + const map = new Map(); + for (const row of rows) { + map.set(row.path, { total_entries: row.total_entries, file_mtime: row.file_mtime }); + } + return map; +} + +/** List all session JSONL files on disk */ +export function listSessionFiles(): string[] { + const files: string[] = []; + try { + const dirs = readdirSync(SESSIONS_DIR); + for (const dir of dirs) { + const dirPath = join(SESSIONS_DIR, dir); + try { + const entries = readdirSync(dirPath); + for (const file of entries) { + if (file.endsWith(".jsonl")) { + files.push(join(dirPath, file)); + } + } + } catch { + // Not a directory or unreadable + } + } + } catch { + // Sessions dir doesn't exist + } + return files; +} + +/** Search indexed sessions using FTS5 + BM25 ranking */ +export function searchSessions(query: string, limit: number = 10): SearchResult[] { + const db = getDb(); + if (!_ftsAvailable) { + // Fallback: LIKE search + const pattern = `%${query.replace(/%/g, "%%")}%`; + const rows = db.prepare(` + SELECT * FROM sessions + WHERE search_text LIKE ? OR name LIKE ? OR cwd LIKE ? + ORDER BY last_activity_at DESC + LIMIT ? + `).all(pattern, pattern, pattern, limit) as any[]; + return rows + .filter((r) => existsSync(r.path)) + .map((r) => ({ record: rowToRecord(r), rank: 0 })); + } + + const trimmed = query.trim(); + if (!trimmed) return []; + + // Build FTS5 query with prefix matching on last token + const tokens = trimmed.split(/\s+/).filter(Boolean); + const ftsTokens = tokens.map((w, i) => { + const escaped = w.replace(/"/g, '""'); + return i === tokens.length - 1 ? `"${escaped}"*` : `"${escaped}"`; + }); + const ftsQuery = ftsTokens.join(" AND "); + + try { + const rows = db.prepare(` + SELECT s.*, rank + FROM sessions s + JOIN sessions_fts fts ON s.rowid = fts.rowid + WHERE sessions_fts MATCH ? + ORDER BY rank + LIMIT ? + `).all(ftsQuery, limit) as any[]; + return rows + .filter((r) => existsSync(r.path)) + .map((r) => ({ record: rowToRecord(r), rank: r.rank })); + } catch { + // FTS syntax error — fall through to LIKE + const pattern = `%${trimmed.replace(/%/g, "%%")}%`; + const rows = db.prepare(` + SELECT * FROM sessions + WHERE search_text LIKE ? OR name LIKE ? OR cwd LIKE ? + ORDER BY last_activity_at DESC + LIMIT ? + `).all(pattern, pattern, pattern, limit) as any[]; + return rows + .filter((r) => existsSync(r.path)) + .map((r) => ({ record: rowToRecord(r), rank: 0 })); + } +} + +/** Get a single session record by path */ +export function getSession(path: string): SessionRecord | null { + const db = getDb(); + const row = db.prepare("SELECT * FROM sessions WHERE path = ?").get(path) as any; + return row ? rowToRecord(row) : null; +} + +/** Get database stats */ +export function getStats(): IndexStats { + const db = getDb(); + const countRow = db.prepare("SELECT COUNT(*) as count FROM sessions").get() as any; + let size = 0; + try { + size = statSync(DB_PATH).size; + } catch { + // ignore + } + return { + totalSessions: countRow?.count ?? 0, + dbSizeBytes: size, + }; +} + +/** Close database connection */ +export function closeDb(): void { + if (_db) { + _db.close(); + _db = null; + } +} + +function rowToRecord(row: any): SessionRecord { + return { + path: row.path, + id: row.id, + cwd: row.cwd, + name: row.name ?? null, + created_at: row.created_at, + last_activity_at: row.last_activity_at, + user_message_count: row.user_message_count, + assistant_message_count: row.assistant_message_count, + total_entries: row.total_entries, + file_size_bytes: row.file_size_bytes, + file_mtime: row.file_mtime ?? 0, + indexed_at: row.indexed_at, + search_text: row.search_text, + }; +} diff --git a/extensions/session-store/index.ts b/extensions/session-store/index.ts new file mode 100644 index 0000000..a39f183 --- /dev/null +++ b/extensions/session-store/index.ts @@ -0,0 +1,301 @@ +/** + * session-store — Full-text search across all pi sessions via SQLite FTS5 + BM25 + * + * No archiving, no cleanup — purely a search index. + * + * Features: + * /search — Interactive FTS search with preview + * search_sessions — LLM tool for querying across sessions + * Auto-index on agent_end + catch-up on session_start + */ + +import { statSync } from "node:fs"; +import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { Container, Markdown, Text, getMarkdownTheme } from "@mariozechner/pi-tui"; +import { Type } from "typebox"; +import { + indexSession, + searchSessions, + getIndexedSessionsWithMtime, + listSessionFiles, + pruneMissingSessions, + closeDb, + getStats, + isFtsAvailable, +} from "./db.js"; +import { SearchOverlay } from "./search-overlay.js"; + +let initialCatchUpDone = false; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + + + +/** Index the current session if needed */ +function maybeIndexCurrent(ctx: ExtensionContext): void { + const sessionFile = ctx.sessionManager.getSessionFile(); + if (!sessionFile) return; + + let stat: { mtimeMs: number }; + try { + stat = statSync(sessionFile); + } catch { + return; + } + + const indexed = getIndexedSessionsWithMtime(); + const expectedEntries = ctx.sessionManager.getEntries().length; + const dbRecord = indexed.get(sessionFile); + + if ( + dbRecord === undefined || + dbRecord.total_entries !== expectedEntries || + dbRecord.file_mtime !== stat.mtimeMs + ) { + try { + indexSession(sessionFile); + } catch (err) { + console.error("[session-store] Failed to index current session:", err); + } + } +} + +/** Catch-up scan: index all sessions not yet in DB */ +async function catchUpScan(ctx: ExtensionContext): Promise { + if (initialCatchUpDone) return; + initialCatchUpDone = true; + + const files = listSessionFiles(); + if (files.length === 0) return; + + const indexed = getIndexedSessionsWithMtime(); + const existingPaths = new Set(files); + let newCount = 0; + let removedCount = 0; + let errorCount = 0; + + try { + removedCount = pruneMissingSessions(existingPaths); + } catch (err) { + errorCount++; + console.error("[session-store] Failed to prune missing sessions:", err); + } + + for (const file of files) { + let stat: { size: number; mtimeMs: number }; + try { + stat = statSync(file); + } catch { + continue; + } + + const dbRecord = indexed.get(file); + if ( + dbRecord !== undefined && + dbRecord.file_mtime !== null && + dbRecord.file_mtime === stat.mtimeMs && + dbRecord.total_entries !== undefined + ) { + continue; + } + + try { + indexSession(file); + newCount++; + } catch (err) { + errorCount++; + console.error(`[session-store] Failed to index ${file}:`, err); + } + } + + if (newCount > 0 || removedCount > 0 || errorCount > 0) { + const stats = getStats(); + const removed = removedCount > 0 ? `, pruned ${removedCount}` : ""; + const msg = `🔍 Indexed ${newCount} sessions${removed} (${stats.totalSessions} total, ${formatBytes(stats.dbSizeBytes)})`; + if (ctx.hasUI) { + ctx.ui.setStatus("session-store", msg); + setTimeout(() => ctx.ui.setStatus("session-store", undefined), 5000); + } + } +} + +export default function (pi: ExtensionAPI) { + // ── /search — Interactive FTS search ── + pi.registerCommand("search", { + description: "Search across all indexed sessions via FTS5 + BM25", + handler: async (_args, ctx: ExtensionCommandContext) => { + if (!isFtsAvailable()) { + ctx.ui.notify("FTS5 not available — cannot search", "error"); + return; + } + + if (!ctx.hasUI) { + ctx.ui.notify("session-store requires interactive mode", "error"); + return; + } + + const action = await ctx.ui.custom< + { type: "select"; path: string } | { type: "close" } + >( + (tui, theme, _kb, done) => { + return new SearchOverlay({ + theme, + getTermRows: () => tui.terminal?.rows ?? 40, + getTermCols: () => tui.terminal?.cols ?? 120, + requestRender: () => tui.requestRender(), + onDone: done, + onSearch: (query) => searchSessions(query, 20), + }); + }, + { + overlay: true, + overlayOptions: { + anchor: "center", + width: "90%", + minWidth: 80, + maxHeight: "90%", + }, + }, + ); + + if (action.type === "select") { + // Print the selected session path so user can reference it + ctx.log(`Selected session: ${action.path}`); + } + }, + }); + + // ── search_sessions — LLM tool ── + pi.registerTool({ + name: "search_sessions", + label: "Search Sessions", + description: + "Search across all indexed pi sessions using full-text search (FTS5 + BM25). " + + "Returns matching sessions ranked by relevance. Use to find sessions related to a " + + "topic, technology, task, or decision without loading individual session files. " + + "The results include session path, project directory, and matching text snippets.", + promptSnippet: "search_sessions — find relevant past sessions by keyword", + parameters: Type.Object({ + query: Type.String({ + description: + "Search query. Supports plain keywords (AND by default), exact phrases in quotes, " + + "and prefix matching (e.g., 'auth middleware').", + }), + limit: Type.Optional( + Type.Number({ + default: 10, + description: "Maximum number of results to return (default: 10)", + }), + ), + }), + + async execute(_toolCallId, params, _signal, onUpdate, _ctx) { + const { query, limit = 10 } = params; + + if (!isFtsAvailable()) { + return { + content: [ + { + type: "text" as const, + text: "Error: FTS5 is not available in this SQLite build.", + }, + ], + details: { error: true }, + }; + } + + onUpdate?.({ + content: [{ type: "text", text: `Searching: "${query}"` }], + details: { status: "loading", query }, + }); + + const results = searchSessions(query, limit); + + if (results.length === 0) { + return { + content: [ + { + type: "text" as const, + text: `No sessions found for: "${query}"`, + }, + ], + details: { empty: true, query }, + }; + } + + const lines: string[] = [ + `Found ${results.length} session${results.length === 1 ? "" : "s"} for "${query}":`, + "", + ]; + + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const s = r.record; + const proj = s.cwd ? s.cwd.split("/").pop() || s.cwd : "unknown"; + const msgs = s.user_message_count + s.assistant_message_count; + const date = new Date(s.last_activity_at).toISOString().slice(0, 10); + + lines.push(`${i + 1}. **${proj}** · ${date} · ${msgs} msgs · rank ${r.rank.toFixed(2)}`); + lines.push(` Path: \`${s.path}\``); + + // Show first matching snippet from search_text + const firstLine = s.search_text.split("\n")[0]?.trim(); + if (firstLine && firstLine.length > 10) { + const snippet = firstLine.length > 120 ? firstLine.slice(0, 120) + "…" : firstLine; + lines.push(` > ${snippet}`); + } + lines.push(""); + } + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + details: { + query, + resultCount: results.length, + }, + }; + }, + + renderResult(result, _options, theme, _ctx) { + const container = new Container(); + const firstContent = result.content[0]; + if (firstContent && firstContent.type === "text") { + const text = firstContent.text; + const mdTheme = getMarkdownTheme(); + const md = new Markdown(text, 0, 0, mdTheme, { + color: (text: string) => theme.fg("toolOutput", text), + }); + container.addChild(md); + } + return container; + }, + }); + + // ── session_start — catch-up scan ── + pi.on("session_start", async (_event, ctx) => { + try { + await catchUpScan(ctx); + } catch (err) { + console.error("[session-store] Catch-up scan failed:", err); + } + }); + + // ── agent_end — incremental index of current session ── + pi.on("agent_end", async (_event, ctx) => { + try { + maybeIndexCurrent(ctx); + } catch (err) { + console.error("[session-store] Incremental index failed:", err); + } + }); + + // ── session_shutdown — close DB ── + pi.on("session_shutdown", async () => { + closeDb(); + initialCatchUpDone = false; + }); +} diff --git a/extensions/session-store/search-overlay.ts b/extensions/session-store/search-overlay.ts new file mode 100644 index 0000000..9a1f221 --- /dev/null +++ b/extensions/session-store/search-overlay.ts @@ -0,0 +1,435 @@ +/** + * Search overlay — interactive FTS search across all indexed sessions. + * + * ╭── search input ────────────┬── preview ─────────────────────╮ + * │ ▸ session 1 │ session title │ + * │ session 2 │ metadata │ + * │ session 3 │ ──────────────────────────── │ + * │ │ USER │ + * │ │ message preview... │ + * │ │ AGENT │ + * │ │ response preview... │ + * ╰─────────────────────────────┴───────────────────────────────╯ + */ + +import type { Component, Focusable } from "@mariozechner/pi-tui"; +import { Input, Markdown, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import type { Theme } from "@mariozechner/pi-coding-agent"; +import { getMarkdownTheme } from "@mariozechner/pi-coding-agent"; +import type { SessionRecord, SearchResult } from "./db.js"; +import { readFileSync } from "node:fs"; + +export type SearchAction = + | { type: "select"; path: string } + | { type: "close" }; + +interface SearchOverlayOptions { + theme: Theme; + getTermRows: () => number; + getTermCols: () => number; + requestRender: () => void; + onDone: (action: SearchAction) => void; + onSearch: (query: string) => SearchResult[]; +} + +function padTo(s: string, w: number): string { + const vis = visibleWidth(s); + if (vis >= w) return truncateToWidth(s, w); + return s + " ".repeat(w - vis); +} + +function relativeTime(ms: number): string { + const diff = Date.now() - ms; + const minutes = Math.floor(diff / 60_000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + if (days > 30) return `${Math.floor(days / 30)}mo`; + if (days > 0) return `${days}d`; + if (hours > 0) return `${hours}h`; + if (minutes > 0) return `${minutes}m`; + return "now"; +} + +function projectName(cwd: string): string { + if (!cwd) return "???"; + const parts = cwd.split("/"); + return parts[parts.length - 1] || parts[parts.length - 2] || cwd; +} + +// ── Preview cache ──────────────────────────────────────────────────────────── + +interface PreviewBlock { + role: "user" | "assistant"; + text: string; +} + +const previewCache = new Map(); + +function loadPreview(path: string): PreviewBlock[] { + const cached = previewCache.get(path); + if (cached) return cached; + + const blocks: PreviewBlock[] = []; + try { + const raw = readFileSync(path, "utf-8"); + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + if (entry.type !== "message" || !entry.message) continue; + const msg = entry.message; + if (msg.role !== "user" && msg.role !== "assistant") continue; + const text = extractText(msg.content); + if (text.trim()) { + blocks.push({ role: msg.role, text: text.trim() }); + } + } catch { /* skip */ } + } + } catch { + return []; + } + + // Keep last N messages for preview + const result = blocks.length > 30 ? blocks.slice(blocks.length - 30) : blocks; + previewCache.set(path, result); + return result; +} + +function extractText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((b: any) => b.type === "text") + .map((b: any) => b.text) + .join("\n"); + } + return ""; +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +const LEFT_HEADER_LINES = 3; + +// ── Main component ─────────────────────────────────────────────────────────── + +export class SearchOverlay implements Component, Focusable { + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + + private results: SearchResult[] = []; + private selectedIndex = 0; + private scrollOffset = 0; + private searchInput: Input; + private theme: Theme; + private mdTheme: any; + private getTermRows: () => number; + private getTermCols: () => number; + private requestRender: () => void; + private onDone: (action: SearchAction) => void; + private onSearch: (query: string) => SearchResult[]; + private lastSelectedPath: string | undefined; + + constructor(opts: SearchOverlayOptions) { + this.theme = opts.theme; + this.mdTheme = getMarkdownTheme(); + this.getTermRows = opts.getTermRows; + this.getTermCols = opts.getTermCols; + this.requestRender = opts.requestRender; + this.onDone = opts.onDone; + this.onSearch = opts.onSearch; + + this.searchInput = new Input(); + } + + private doSearch(): void { + const query = this.searchInput.getValue().trim(); + this.results = query ? this.onSearch(query) : []; + this.selectedIndex = 0; + this.scrollOffset = 0; + } + + handleInput(data: string): void { + // Escape + if (data === "\x1b") { + if (this.searchInput.getValue()) { + this.searchInput.setValue(""); + this.doSearch(); + this.requestRender(); + return; + } + this.onDone({ type: "close" }); + return; + } + + // Navigation + if (data === "\x1b[A") { // up + this.moveSelection(-1); + this.requestRender(); + return; + } + if (data === "\x1b[B") { // down + this.moveSelection(1); + this.requestRender(); + return; + } + if (data === "\t") { + this.moveSelection(1); + this.requestRender(); + return; + } + if (data === "\x1b[Z") { // shift+tab + this.moveSelection(-1); + this.requestRender(); + return; + } + + // Actions + if (data === "\r" || data === "\n") { + const result = this.results[this.selectedIndex]; + if (result) { + this.onDone({ type: "select", path: result.record.path }); + } + return; + } + if (data === "\x15") { // ctrl+u + this.searchInput.setValue(""); + this.doSearch(); + this.requestRender(); + return; + } + + // Everything else → search input + this.searchInput.handleInput(data); + this.doSearch(); + this.requestRender(); + } + + private moveSelection(delta: number): void { + const len = this.results.length; + if (len === 0) return; + this.selectedIndex = Math.max(0, Math.min(len - 1, this.selectedIndex + delta)); + + const maxItems = this.getMaxListItems(); + if (this.selectedIndex < this.scrollOffset) { + this.scrollOffset = this.selectedIndex; + } else if (this.selectedIndex >= this.scrollOffset + maxItems) { + this.scrollOffset = this.selectedIndex - maxItems + 1; + } + } + + private getContentHeight(): number { + const termRows = this.getTermRows(); + return Math.max(10, Math.floor(termRows * 0.80) - 2); + } + + private getMaxListItems(): number { + const contentH = this.getContentHeight(); + const available = contentH - LEFT_HEADER_LINES; + return Math.max(1, Math.floor(available / 2)); + } + + // ── Render ───────────────────────────────────────────────────────────────── + + render(width: number): string[] { + const th = this.theme; + const innerW = width - 2; + const leftW = Math.max(30, Math.floor(innerW * 0.40)); + const rightW = innerW - leftW - 1; + + const targetH = this.getContentHeight(); + + // Left panel: search + results + const leftLines = this.renderLeftPanel(leftW, targetH); + + // Right panel: preview + const selected = this.results[this.selectedIndex]; + const rightLines = selected + ? this.buildPreview(selected.record, rightW, targetH) + : this.centeredMessage(th.fg("dim", "(no session selected)"), rightW, targetH); + + // Assemble framed output + const lines: string[] = []; + const sep = th.fg("border", "│"); + + // Top border + lines.push(th.fg("border", "╭" + "─".repeat(leftW) + "┬" + "─".repeat(rightW) + "╮")); + + // Content rows + for (let i = 0; i < targetH; i++) { + lines.push( + sep + padTo(leftLines[i] ?? "", leftW) + sep + padTo(rightLines[i] ?? "", rightW) + sep, + ); + } + + // Bottom border with hints + const footer = " " + + th.fg("dim", "↑↓") + " " + th.fg("muted", "nav") + " " + + th.fg("dim", "⏎") + " " + th.fg("muted", "select") + " " + + th.fg("dim", "^U") + " " + th.fg("muted", "clear") + " " + + th.fg("dim", "esc") + " " + th.fg("muted", "close"); + + lines.push( + th.fg("border", "╰") + padTo(footer, leftW + 1 + rightW) + th.fg("border", "╯"), + ); + + return lines; + } + + // ── Left panel ───────────────────────────────────────────────────────────── + + private renderLeftPanel(w: number, totalH: number): string[] { + const th = this.theme; + const lines: string[] = []; + + // Title + count + const title = th.bold(th.fg("accent", " 🔍 Search")); + const count = th.fg("dim", ` (${this.results.length})`); + lines.push(title + count); + + // Search input + const inputLines = this.searchInput.render(w - 5); + lines.push(" " + th.fg("accent", "🔎") + " " + (inputLines[0] ?? "")); + + // Separator + lines.push(th.fg("border", " " + "─".repeat(Math.max(1, w - 2)))); + + // Results list + const results = this.results; + const maxItems = this.getMaxListItems(); + + if (results.length === 0) { + const msg = this.searchInput.getValue() ? "(no matches)" : "(type to search)"; + lines.push(""); + lines.push(th.fg("dim", " " + msg)); + } else { + const end = Math.min(results.length, this.scrollOffset + maxItems); + for (let i = this.scrollOffset; i < end; i++) { + const r = results[i]; + const s = r.record; + const isSel = i === this.selectedIndex; + + const name = s.name || s.search_text.split("\n")[0]?.trim() || "(unnamed)"; + const time = relativeTime(s.last_activity_at); + const msgs = `${s.user_message_count + s.assistant_message_count}msg`; + const proj = projectName(s.cwd); + + // Line 1: cursor + name + time + let primary: string; + if (isSel) { + primary = th.fg("accent", " ▸ ") + th.bold(truncateToWidth(name, w - 12)) + " " + th.fg("dim", time); + } else { + primary = " " + th.fg("muted", truncateToWidth(name, w - 12)) + " " + th.fg("dim", time); + } + lines.push(truncateToWidth(primary, w)); + + // Line 2: project + metadata + const meta = ` ${th.fg("dim", proj)} · ${th.fg("dim", msgs)}`; + lines.push(truncateToWidth(meta, w)); + } + + if (results.length > maxItems) { + lines.push(th.fg("dim", ` ${this.scrollOffset + 1}–${end} of ${results.length}`)); + } + } + + while (lines.length < totalH) lines.push(""); + return lines.slice(0, totalH); + } + + // ── Right panel: preview ─────────────────────────────────────────────────── + + private buildPreview(session: SessionRecord, w: number, h: number): string[] { + const th = this.theme; + const lines: string[] = []; + + if (session.path !== this.lastSelectedPath) { + this.lastSelectedPath = session.path; + } + + // Header + const name = session.name || session.search_text.split("\n")[0]?.trim() || "(unnamed)"; + lines.push(truncateToWidth(" " + th.bold(th.fg("accent", name)), w)); + + const msgs = `${session.user_message_count + session.assistant_message_count} msgs`; + const time = relativeTime(session.last_activity_at); + const cwd = session.cwd || ""; + lines.push(truncateToWidth(" " + th.fg("dim", `${msgs} · ${time} · ${cwd}`), w)); + lines.push(th.fg("border", " " + "─".repeat(Math.max(0, w - 2)))); + + // Conversation preview + const blocks = loadPreview(session.path); + const headerH = lines.length; + const contentH = h - headerH; + + if (blocks.length === 0 || contentH <= 0) { + const emptyLines = this.centeredMessage(th.fg("dim", "(no preview)"), w, Math.max(0, contentH)); + lines.push(...emptyLines); + while (lines.length < h) lines.push(""); + return lines.slice(0, h); + } + + const allContentLines: string[] = []; + let lastRole: string | undefined; + + for (const block of blocks) { + if (allContentLines.length > 0) allContentLines.push(""); + + if (block.role === "user") { + const pill = th.bold(th.inverse(th.fg("accent", " USER "))); + allContentLines.push(" " + pill); + + const bgBlank = th.bg("userMessageBg", " ".repeat(w)); + allContentLines.push(bgBlank); + + const md = new Markdown(block.text, 1, 0, this.mdTheme, { + bgColor: (text: string) => th.bg("userMessageBg", text), + color: (text: string) => th.fg("userMessageText", text), + }); + for (const line of md.render(w)) { + allContentLines.push(th.bg("userMessageBg", padTo(line, w))); + } + allContentLines.push(bgBlank); + } else { + if (lastRole !== "assistant") { + const pill = th.bold(th.inverse(th.fg("success", " AGENT "))); + allContentLines.push(" " + pill); + } + + const md = new Markdown(block.text, 1, 0, this.mdTheme); + allContentLines.push(...md.render(w)); + } + + lastRole = block.role; + } + + for (let i = 0; i < contentH; i++) { + if (i < allContentLines.length) { + lines.push(truncateToWidth(allContentLines[i], w)); + } else { + lines.push(""); + } + } + + return lines.slice(0, h); + } + + private centeredMessage(msg: string, w: number, h: number): string[] { + if (h <= 0) return []; + const mid = Math.floor(h / 2); + const vis = visibleWidth(msg); + const padLeft = Math.max(0, Math.floor((w - vis) / 2)); + return Array.from({ length: h }, (_, i) => (i === mid ? " ".repeat(padLeft) + msg : "")); + } + + invalidate(): void {} + + dispose(): void { + previewCache.clear(); + } +} -- 2.51.2 From 0446348a0bde13dafb61aeb9f06ad664eecbfcc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:30:58 +0200 Subject: [PATCH 25/66] feat(pr-review-comments): add PR review comments skill --- skills/pr-review-comments/SKILL.md | 133 +++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 skills/pr-review-comments/SKILL.md diff --git a/skills/pr-review-comments/SKILL.md b/skills/pr-review-comments/SKILL.md new file mode 100644 index 0000000..9633cb5 --- /dev/null +++ b/skills/pr-review-comments/SKILL.md @@ -0,0 +1,133 @@ +--- +name: pr-review-comments +description: "Review, triage, and resolve PR review comments. Use when user asks to check PR comments, address review feedback, fix issues raised in review, reply to reviewers, or resolve review threads." +--- + +# PR Review Comments + +Triage and address review comments on a pull request: read each comment, decide if valid, fix code, verify tests pass, reply, and resolve threads. + +## Prerequisites + +- `gh` CLI authenticated +- Working directory inside the repo (or use `--repo owner/repo`) + +## Workflow + +### 1. Fetch review comments + +Get all review comments with context: + +```bash +gh api repos/{owner}/{repo}/pulls/{pr}/comments \ + --jq '.[] | {id, path, line, body, user: .user.login}' +``` + +Also check top-level PR comments (non-inline): + +```bash +gh api repos/{owner}/{repo}/issues/{pr}/comments \ + --jq '.[] | {id, body, user: .user.login}' +``` + +### 2. Triage each comment + +For each comment, decide: + +- **Valid & actionable** → fix the code +- **Valid but won't fix** → reply with rationale +- **Invalid / misunderstanding** → reply with explanation +- **Question** → answer it + +Read the relevant file context before deciding. Don't blindly accept or reject. + +### 3. Fix code + +Apply fixes for valid comments. Group related fixes into a single commit. Use conventional commit style: + +``` +fix(): address PR review comments + +- +- +``` + +Stage, commit, and push. + +### 4. Validate tests pass + +After fixing code, **always run tests before pushing** to make sure the fixes don't break anything: + +- Run the project's test command (check project docs / `CLAUDE.md` / `Makefile` / `mise` tasks for the right command). +- If tests fail, fix the issue before pushing. A review fix that breaks CI is worse than the original comment. +- If the fix changes test expectations (e.g., updated output values), update the tests too. +- Pay special attention to mock/stub setups — removing or changing mocks can cause unrelated tests to fail when test infrastructure intercepts more than expected (e.g., `mock_provider` intercepting locally-evaluated data sources). + +Only push once all tests are green. + +### 5. Reply to comments + +Reply to each review comment thread using the `in_reply_to` field: + +```bash +gh api -X POST repos/{owner}/{repo}/pulls/{pr}/comments \ + -f body="" \ + -F in_reply_to= +``` + +**Important:** The `in_reply_to` parameter must be a number (`-F`), not a string (`-f`). Use `-F` for numeric values. + +Keep replies concise: state what was done or why not. + +### 6. Resolve threads + +Fetch unresolved thread node IDs via GraphQL: + +```bash +gh api graphql -f query=' +{ + repository(owner: "{owner}", name: "{repo}") { + pullRequest(number: {pr}) { + reviewThreads(first: 50) { + nodes { + id + isResolved + comments(first: 1) { + nodes { databaseId body } + } + } + } + } + } +}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {id, commentId: .comments.nodes[0].databaseId, body: (.comments.nodes[0].body[:80])}' +``` + +Resolve each thread after replying: + +```bash +gh api graphql -f query=' +mutation { + resolveReviewThread(input: {threadId: ""}) { + thread { isResolved } + } +}' +``` + +Loop over all thread IDs to resolve in batch: + +```bash +for tid in PRRT_abc PRRT_def PRRT_ghi; do + gh api graphql -f query="mutation { resolveReviewThread(input: {threadId: \"$tid\"}) { thread { isResolved } } }" +done +``` + +## Rules + +- **Read before deciding.** Always read the file/context around the commented line before accepting or rejecting. +- **Don't blindly accept.** Evaluate each comment on merit. Some automated/AI review comments are wrong. +- **Don't blindly reject.** If a comment points out a real issue, fix it even if the wording is off. +- **Test before pushing.** Run the full test suite (or at minimum the affected module's tests) after every fix. Never push code you haven't validated. +- **One commit for all fixes.** Don't create separate commits per comment unless changes are unrelated. +- **Reply to every comment.** Even if resolved by a code change, confirm what was done. +- **Resolve only after replying.** Don't resolve threads without a reply — reviewers need to see acknowledgment. +- **Push before replying.** The fix commit should be visible in the PR when the reviewer reads the reply. -- 2.51.2 From b35810ee45eef5f43827ea6f8d69ad508abcfeba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:30:58 +0200 Subject: [PATCH 26/66] refactor(session-snap): remove session-snap extension --- extensions/session-snap/archive-overlay.ts | 439 --------------------- extensions/session-snap/archive.ts | 312 --------------- extensions/session-snap/index.ts | 209 ---------- extensions/session-snap/scanner.ts | 195 --------- extensions/session-snap/sweep-overlay.ts | 302 -------------- extensions/session-snap/types.ts | 65 --- 6 files changed, 1522 deletions(-) delete mode 100644 extensions/session-snap/archive-overlay.ts delete mode 100644 extensions/session-snap/archive.ts delete mode 100644 extensions/session-snap/index.ts delete mode 100644 extensions/session-snap/scanner.ts delete mode 100644 extensions/session-snap/sweep-overlay.ts delete mode 100644 extensions/session-snap/types.ts diff --git a/extensions/session-snap/archive-overlay.ts b/extensions/session-snap/archive-overlay.ts deleted file mode 100644 index c8f2ced..0000000 --- a/extensions/session-snap/archive-overlay.ts +++ /dev/null @@ -1,439 +0,0 @@ -/** - * Archive overlay — mirrors session-switch's SplitOverlay look & feel. - * - * ╭── search input ────────────┬── preview ─────────────────────╮ - * │ ▸ 2026-02-22 pi-ext │ pi-ext │ - * │ 2026-02-23 lgtm-k8s │ 2026-02-22 20:12 · 12 msgs │ - * │ 2026-02-24 skald │ ──────────────────────────── │ - * │ ... │ USER │ - * │ │ Add custom footer extension │ - * │ │ AGENT │ - * │ │ I'll create a new... │ - * ╰─────────────────────────────┴───────────────────────────────╯ - */ - -import type { Component, Focusable } from "@mariozechner/pi-tui"; -import { Input, Markdown, truncateToWidth, visibleWidth, CURSOR_MARKER } from "@mariozechner/pi-tui"; -import { matchesKey } from "@mariozechner/pi-tui"; -import type { Theme } from "@mariozechner/pi-coding-agent"; -import { getMarkdownTheme } from "@mariozechner/pi-coding-agent"; -import type { ArchivedSession } from "./types.js"; -import { type MessageBlock, getArchivedMessageBlocks } from "./archive.js"; -import { formatBytes, formatDuration, projectName } from "./scanner.js"; - -export type ArchiveAction = - | { type: "restore"; sessionId: string } - | { type: "delete"; sessionId: string } - | { type: "close" }; - -interface ArchiveOverlayOptions { - sessions: ArchivedSession[]; - theme: Theme; - getTermRows: () => number; - getTermCols: () => number; - requestRender: () => void; - onDone: (action: ArchiveAction) => void; - onSearch: (query: string) => ArchivedSession[]; -} - -/** Pad or truncate to exact visible width */ -function padTo(s: string, w: number): string { - const vis = visibleWidth(s); - if (vis >= w) return truncateToWidth(s, w); - return s + " ".repeat(w - vis); -} - -function relativeTime(ms: number): string { - const diff = Date.now() - ms; - const minutes = Math.floor(diff / 60_000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - if (days > 30) return `${Math.floor(days / 30)}mo`; - if (days > 0) return `${days}d`; - if (hours > 0) return `${hours}h`; - if (minutes > 0) return `${minutes}m`; - return "now"; -} - -// ── Preview cache ──────────────────────────────────────────────────────────── - -const previewCache = new Map(); - -function getMessageBlocks(sessionId: string): MessageBlock[] { - const cached = previewCache.get(sessionId); - if (cached) return cached; - const blocks = getArchivedMessageBlocks(sessionId); - previewCache.set(sessionId, blocks); - return blocks; -} - -// ── Left panel header: title + search + separator = 3 lines ────────────────── -const LEFT_HEADER_LINES = 3; - -// ── Main component ─────────────────────────────────────────────────────────── - -export class ArchiveOverlay implements Component, Focusable { - private _focused = false; - get focused(): boolean { - return this._focused; - } - set focused(value: boolean) { - this._focused = value; - this.searchInput.focused = value; - } - - private allSessions: ArchivedSession[]; - private filteredSessions: ArchivedSession[]; - private selectedIndex = 0; - private scrollOffset = 0; - private searchInput: Input; - private confirmDelete = false; - private theme: Theme; - private mdTheme: any; - private getTermRows: () => number; - private getTermCols: () => number; - private requestRender: () => void; - private onDone: (action: ArchiveAction) => void; - private onSearch: (query: string) => ArchivedSession[]; - - private lastSelectedId: string | undefined; - - constructor(opts: ArchiveOverlayOptions) { - this.allSessions = opts.sessions; - this.filteredSessions = opts.sessions; - this.theme = opts.theme; - this.mdTheme = getMarkdownTheme(); - this.getTermRows = opts.getTermRows; - this.getTermCols = opts.getTermCols; - this.requestRender = opts.requestRender; - this.onDone = opts.onDone; - this.onSearch = opts.onSearch; - - this.searchInput = new Input(); - } - - private doSearch(): void { - const query = this.searchInput.getValue().trim(); - this.filteredSessions = query ? this.onSearch(query) : this.allSessions; - this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); - this.scrollOffset = 0; - } - - handleInput(data: string): void { - // ── Confirm delete ── - if (this.confirmDelete) { - if (data === "y" || data === "Y") { - const session = this.filteredSessions[this.selectedIndex]; - if (session) { - this.confirmDelete = false; - this.onDone({ type: "delete", sessionId: session.id }); - } - } else { - this.confirmDelete = false; - this.requestRender(); - } - return; - } - - // ── Escape ── - if (matchesKey(data, "escape")) { - if (this.searchInput.getValue()) { - this.searchInput.setValue(""); - this.doSearch(); - this.requestRender(); - return; - } - this.onDone({ type: "close" }); - return; - } - - // ── Navigation ── - if (matchesKey(data, "up")) { - this.moveSelection(-1); - this.requestRender(); - return; - } - if (matchesKey(data, "down")) { - this.moveSelection(1); - this.requestRender(); - return; - } - if (matchesKey(data, "tab")) { - this.moveSelection(1); - this.requestRender(); - return; - } - if (matchesKey(data, "shift+tab")) { - this.moveSelection(-1); - this.requestRender(); - return; - } - - // ── Actions ── - if (matchesKey(data, "return")) { - const session = this.filteredSessions[this.selectedIndex]; - if (session) this.onDone({ type: "restore", sessionId: session.id }); - return; - } - if (matchesKey(data, "ctrl+d")) { - if (this.filteredSessions[this.selectedIndex]) { - this.confirmDelete = true; - this.requestRender(); - } - return; - } - if (matchesKey(data, "ctrl+u")) { - this.searchInput.setValue(""); - this.doSearch(); - this.requestRender(); - return; - } - - // ── Everything else → search input ── - this.searchInput.handleInput(data); - this.doSearch(); - this.requestRender(); - } - - private moveSelection(delta: number): void { - const len = this.filteredSessions.length; - if (len === 0) return; - this.selectedIndex = Math.max(0, Math.min(len - 1, this.selectedIndex + delta)); - - // How many items (not lines) fit in the list area - const maxItems = this.getMaxListItems(); - if (this.selectedIndex < this.scrollOffset) { - this.scrollOffset = this.selectedIndex; - } else if (this.selectedIndex >= this.scrollOffset + maxItems) { - this.scrollOffset = this.selectedIndex - maxItems + 1; - } - } - - /** - * Compute the total content height (between top and bottom borders). - * overlay maxHeight is 85% of terminal, minus 2 for top/bottom border rows. - */ - private getContentHeight(): number { - const termRows = this.getTermRows(); - return Math.max(10, Math.floor(termRows * 0.80) - 2); - } - - /** - * How many session items fit in the list area. - * Each item = 2 lines (name + metadata), plus LEFT_HEADER_LINES for header. - */ - private getMaxListItems(): number { - const contentH = this.getContentHeight(); - const available = contentH - LEFT_HEADER_LINES; - return Math.max(1, Math.floor(available / 2)); - } - - // ── Render ───────────────────────────────────────────────────────────────── - - render(width: number): string[] { - const th = this.theme; - const innerW = width - 2; // inside │ borders - const leftW = Math.max(25, Math.floor(innerW * 0.40)); - const rightW = innerW - leftW - 1; // -1 for center │ - - // Compute fixed height for content area - const targetH = this.getContentHeight(); - - // ── Left panel: search + list (rendered to exactly targetH lines) ── - const leftLines = this.renderLeftPanel(leftW, targetH); - - // ── Right panel: preview (rendered to exactly targetH lines) ── - const selected = this.filteredSessions[this.selectedIndex]; - const rightLines = selected - ? this.buildPreview(selected, rightW, targetH) - : this.centeredMessage(th.fg("dim", "(no session selected)"), rightW, targetH); - - // ── Assemble framed output ── - const lines: string[] = []; - const sep = th.fg("border", "│"); - - // Top border - lines.push(th.fg("border", "╭" + "─".repeat(leftW) + "┬" + "─".repeat(rightW) + "╮")); - - // Content rows (exactly targetH) - for (let i = 0; i < targetH; i++) { - lines.push( - sep + padTo(leftLines[i] ?? "", leftW) + sep + padTo(rightLines[i] ?? "", rightW) + sep, - ); - } - - // Bottom border with footer hints - const footer = this.confirmDelete - ? " " + th.fg("error", th.bold("⚠ Delete permanently?")) + " " + th.fg("muted", "(y/n)") - : " " + - th.fg("dim", "↑↓") + " " + th.fg("muted", "nav") + " " + - th.fg("dim", "⏎") + " " + th.fg("muted", "restore") + " " + - th.fg("dim", "^D") + " " + th.fg("muted", "delete") + " " + - th.fg("dim", "^U") + " " + th.fg("muted", "clear") + " " + - th.fg("dim", "esc") + " " + th.fg("muted", "close"); - - lines.push( - th.fg("border", "╰") + - padTo(footer, leftW + 1 + rightW) + - th.fg("border", "╯"), - ); - - return lines; - } - - // ── Left panel ───────────────────────────────────────────────────────────── - - private renderLeftPanel(w: number, totalH: number): string[] { - const th = this.theme; - const lines: string[] = []; - - // Title + count - const title = th.bold(th.fg("accent", " 📦 Archive")); - const count = th.fg("dim", ` (${this.filteredSessions.length})`); - lines.push(title + count); - - // Search input - const inputLines = this.searchInput.render(w - 5); - lines.push(" " + th.fg("accent", "🔍") + " " + (inputLines[0] ?? "")); - - // Separator - lines.push(th.fg("border", " " + "─".repeat(Math.max(1, w - 2)))); - - // Session list — fill remaining space - const sessions = this.filteredSessions; - const maxItems = this.getMaxListItems(); - - if (sessions.length === 0) { - const msg = this.searchInput.getValue() ? "(no matches)" : "(empty archive)"; - lines.push(""); - lines.push(th.fg("dim", " " + msg)); - } else { - const end = Math.min(sessions.length, this.scrollOffset + maxItems); - for (let i = this.scrollOffset; i < end; i++) { - const s = sessions[i]; - const isSel = i === this.selectedIndex; - - const name = s.name || s.firstMessage.split("\n")[0]?.trim() || "(unnamed)"; - const time = relativeTime(s.lastActivityAt); - const msgs = `${s.userMessageCount + s.assistantMessageCount}msg`; - const proj = projectName(s.cwd); - - // Line 1: cursor + name + time - let primary: string; - if (isSel) { - primary = th.fg("accent", " ▸ ") + th.bold(truncateToWidth(name, w - 12)) + " " + th.fg("dim", time); - } else { - primary = " " + th.fg("muted", truncateToWidth(name, w - 12)) + " " + th.fg("dim", time); - } - lines.push(truncateToWidth(primary, w)); - - // Line 2: project + metadata - const meta = ` ${th.fg("dim", proj)} · ${th.fg("dim", msgs)} · ${th.fg("dim", formatDuration(s.durationSeconds))}`; - lines.push(truncateToWidth(meta, w)); - } - - // Scroll info (if scrollable) - if (sessions.length > maxItems) { - lines.push(th.fg("dim", ` ${this.scrollOffset + 1}–${end} of ${sessions.length}`)); - } - } - - // Pad to exact height - while (lines.length < totalH) lines.push(""); - return lines.slice(0, totalH); - } - - // ── Right panel: preview ─────────────────────────────────────────────────── - - private buildPreview(session: ArchivedSession, w: number, h: number): string[] { - const th = this.theme; - const lines: string[] = []; - - // Track selection changes - if (session.id !== this.lastSelectedId) { - this.lastSelectedId = session.id; - } - - // ── Header ── - const name = session.name || session.firstMessage.split("\n")[0]?.trim() || "(unnamed)"; - lines.push(truncateToWidth(" " + th.bold(th.fg("accent", name)), w)); - - const msgs = `${session.userMessageCount + session.assistantMessageCount} msgs`; - const time = relativeTime(session.lastActivityAt); - const cwd = session.cwd || ""; - lines.push(truncateToWidth(" " + th.fg("dim", `${msgs} · ${time} · ${cwd}`), w)); - lines.push(th.fg("border", " " + "─".repeat(Math.max(0, w - 2)))); - - // ── Conversation preview ── - const blocks = getMessageBlocks(session.id); - const headerH = lines.length; - const contentH = h - headerH; - - if (blocks.length === 0 || contentH <= 0) { - const emptyLines = this.centeredMessage(th.fg("dim", "(no preview)"), w, Math.max(0, contentH)); - lines.push(...emptyLines); - while (lines.length < h) lines.push(""); - return lines.slice(0, h); - } - - // Render message blocks with Markdown (same style as session-switch) - const allContentLines: string[] = []; - let lastRole: string | undefined; - - for (const block of blocks) { - if (allContentLines.length > 0) allContentLines.push(""); - - if (block.role === "user") { - const pill = th.bold(th.inverse(th.fg("accent", " USER "))); - allContentLines.push(" " + pill); - - const bgBlank = th.bg("userMessageBg", " ".repeat(w)); - allContentLines.push(bgBlank); - - const md = new Markdown(block.text, 1, 0, this.mdTheme, { - bgColor: (text: string) => th.bg("userMessageBg", text), - color: (text: string) => th.fg("userMessageText", text), - }); - for (const line of md.render(w)) { - allContentLines.push(th.bg("userMessageBg", padTo(line, w))); - } - allContentLines.push(bgBlank); - } else { - if (lastRole !== "assistant") { - const pill = th.bold(th.inverse(th.fg("success", " AGENT "))); - allContentLines.push(" " + pill); - } - - const md = new Markdown(block.text, 1, 0, this.mdTheme); - allContentLines.push(...md.render(w)); - } - - lastRole = block.role; - } - - // Fill content area (truncate to available space) - for (let i = 0; i < contentH; i++) { - if (i < allContentLines.length) { - lines.push(truncateToWidth(allContentLines[i], w)); - } else { - lines.push(""); - } - } - - return lines.slice(0, h); - } - - private centeredMessage(msg: string, w: number, h: number): string[] { - if (h <= 0) return []; - const mid = Math.floor(h / 2); - const vis = visibleWidth(msg); - const padLeft = Math.max(0, Math.floor((w - vis) / 2)); - return Array.from({ length: h }, (_, i) => (i === mid ? " ".repeat(padLeft) + msg : "")); - } - - invalidate(): void {} - - dispose(): void { - previewCache.clear(); - } -} diff --git a/extensions/session-snap/archive.ts b/extensions/session-snap/archive.ts deleted file mode 100644 index 3b552bd..0000000 --- a/extensions/session-snap/archive.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { DatabaseSync } from "node:sqlite"; -import { readFileSync, unlinkSync } from "node:fs"; -import { gzipSync, gunzipSync } from "node:zlib"; -import { writeFileSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import { type ArchivedSession, CONFIG } from "./types.js"; -import { type SessionMetrics, extractFullText } from "./scanner.js"; - -let _db: DatabaseSync | null = null; - -/** Get or create the SQLite database connection */ -function getDb(): DatabaseSync { - if (_db) return _db; - - _db = new DatabaseSync(CONFIG.DB_PATH); - - // Enable WAL mode for better concurrent access - _db.exec("PRAGMA journal_mode=WAL"); - - // Create tables - _db.exec(` - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - original_path TEXT NOT NULL, - cwd TEXT, - name TEXT, - first_message TEXT, - created_at INTEGER, - last_activity_at INTEGER, - duration_seconds INTEGER, - user_message_count INTEGER, - assistant_message_count INTEGER, - total_entries INTEGER, - file_size_bytes INTEGER, - archived_at INTEGER, - full_text TEXT - ) - `); - - _db.exec(` - CREATE TABLE IF NOT EXISTS session_data ( - session_id TEXT PRIMARY KEY REFERENCES sessions(id), - jsonl_gzip BLOB NOT NULL - ) - `); - - // FTS5 for full-text search - _db.exec(` - CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5( - full_text, name, first_message, - content='sessions', - content_rowid='rowid' - ) - `); - - // Triggers to keep FTS in sync - _db.exec(` - CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN - INSERT INTO sessions_fts(rowid, full_text, name, first_message) - VALUES (new.rowid, new.full_text, new.name, new.first_message); - END - `); - - _db.exec(` - CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN - INSERT INTO sessions_fts(sessions_fts, rowid, full_text, name, first_message) - VALUES ('delete', old.rowid, old.full_text, old.name, old.first_message); - END - `); - - return _db; -} - -/** Archive a session: store metadata + compressed JSONL, then delete original */ -export function archiveSession(metrics: SessionMetrics): void { - const db = getDb(); - - // Read and compress original JSONL - const raw = readFileSync(metrics.path, "utf-8"); - const compressed = gzipSync(Buffer.from(raw, "utf-8")); - - // Extract full text for search - const fullText = extractFullText(metrics.path); - - // Insert metadata - db.prepare(` - INSERT OR REPLACE INTO sessions - (id, original_path, cwd, name, first_message, created_at, last_activity_at, - duration_seconds, user_message_count, assistant_message_count, - total_entries, file_size_bytes, archived_at, full_text) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - metrics.id, - metrics.path, - metrics.cwd, - metrics.name ?? null, - metrics.firstMessage, - metrics.createdAt, - metrics.lastActivityAt, - metrics.durationSeconds, - metrics.userMessageCount, - metrics.assistantMessageCount, - metrics.totalEntries, - metrics.fileSizeBytes, - Date.now(), - fullText, - ); - - // Insert compressed data - db.prepare(` - INSERT OR REPLACE INTO session_data (session_id, jsonl_gzip) - VALUES (?, ?) - `).run(metrics.id, compressed); - - // Delete original file - unlinkSync(metrics.path); -} - -/** Delete a session file without archiving */ -export function deleteSession(filePath: string): void { - unlinkSync(filePath); -} - -/** List all archived sessions, optionally filtered by search query */ -export function listArchived(searchQuery?: string): ArchivedSession[] { - const db = getDb(); - - if (searchQuery && searchQuery.trim()) { - const trimmed = searchQuery.trim(); - - // Try FTS5 first with prefix matching (append * to last token for live search) - try { - const tokens = trimmed.split(/\s+/).filter(Boolean); - // Each token gets quoted, last one gets prefix wildcard for live search - const ftsTokens = tokens.map((w, i) => { - const escaped = w.replace(/"/g, '""'); - return i === tokens.length - 1 ? `"${escaped}"*` : `"${escaped}"`; - }); - const ftsQuery = ftsTokens.join(" AND "); - - const rows = db - .prepare( - ` - SELECT s.* FROM sessions s - INNER JOIN sessions_fts fts ON s.rowid = fts.rowid - WHERE sessions_fts MATCH ? - ORDER BY rank - LIMIT 200 - `, - ) - .all(ftsQuery) as any[]; - - if (rows.length > 0) return rows.map(rowToArchived); - } catch { - // FTS query syntax error — fall through to LIKE - } - - // Fallback: LIKE search on key fields - const pattern = `%${trimmed}%`; - const rows = db - .prepare( - ` - SELECT * FROM sessions - WHERE first_message LIKE ? OR name LIKE ? OR cwd LIKE ? OR full_text LIKE ? - ORDER BY last_activity_at DESC - LIMIT 200 - `, - ) - .all(pattern, pattern, pattern, pattern) as any[]; - - return rows.map(rowToArchived); - } - - const rows = db - .prepare( - ` - SELECT * FROM sessions - ORDER BY last_activity_at DESC - `, - ) - .all() as any[]; - - return rows.map(rowToArchived); -} - -/** Get count of archived sessions */ -export function getArchivedCount(): number { - const db = getDb(); - const row = db.prepare("SELECT COUNT(*) as count FROM sessions").get() as any; - return row?.count ?? 0; -} - -/** Get total size of archive DB */ -export function getArchiveSize(): number { - try { - const { statSync } = require("node:fs"); - const stat = statSync(CONFIG.DB_PATH); - return stat.size; - } catch { - return 0; - } -} - -/** Restore a session from archive back to its original location */ -export function restoreSession(sessionId: string): string | null { - const db = getDb(); - - // Get metadata - const meta = db.prepare("SELECT * FROM sessions WHERE id = ?").get(sessionId) as any; - if (!meta) return null; - - // Get compressed data - const data = db.prepare("SELECT jsonl_gzip FROM session_data WHERE session_id = ?").get(sessionId) as any; - if (!data) return null; - - // Decompress - const jsonl = gunzipSync(data.jsonl_gzip).toString("utf-8"); - - // Write back to original path - const targetPath = meta.original_path; - mkdirSync(dirname(targetPath), { recursive: true }); - writeFileSync(targetPath, jsonl, "utf-8"); - - // Remove from archive - db.prepare("DELETE FROM session_data WHERE session_id = ?").run(sessionId); - db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId); - - return targetPath; -} - -/** Permanently delete an archived session */ -export function deleteArchived(sessionId: string): void { - const db = getDb(); - db.prepare("DELETE FROM session_data WHERE session_id = ?").run(sessionId); - db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId); -} - -/** Message block for preview rendering */ -export interface MessageBlock { - role: "user" | "assistant"; - text: string; -} - -/** Decompress and extract message blocks from an archived session for preview */ -export function getArchivedMessageBlocks(sessionId: string): MessageBlock[] { - const db = getDb(); - const data = db.prepare("SELECT jsonl_gzip FROM session_data WHERE session_id = ?").get(sessionId) as any; - if (!data) return []; - - try { - const jsonl = gunzipSync(data.jsonl_gzip).toString("utf-8"); - const blocks: MessageBlock[] = []; - - for (const line of jsonl.split("\n")) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - if (entry.type !== "message" || !entry.message) continue; - const msg = entry.message; - if (msg.role !== "user" && msg.role !== "assistant") continue; - - const text = extractMessageText(msg.content); - if (text.trim()) { - blocks.push({ role: msg.role, text: text.trim() }); - } - } catch { /* skip malformed */ } - } - - // Keep last N for preview - return blocks.length > 50 ? blocks.slice(blocks.length - 50) : blocks; - } catch { - return []; - } -} - -function extractMessageText(content: unknown): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter((b: any) => b.type === "text") - .map((b: any) => b.text) - .join("\n"); - } - return ""; -} - -/** Close database connection */ -export function closeDb(): void { - if (_db) { - _db.close(); - _db = null; - } -} - -function rowToArchived(row: any): ArchivedSession { - return { - id: row.id, - originalPath: row.original_path, - cwd: row.cwd, - name: row.name, - firstMessage: row.first_message, - createdAt: row.created_at, - lastActivityAt: row.last_activity_at, - durationSeconds: row.duration_seconds, - userMessageCount: row.user_message_count, - assistantMessageCount: row.assistant_message_count, - totalEntries: row.total_entries, - fileSizeBytes: row.file_size_bytes, - archivedAt: row.archived_at, - fullText: row.full_text, - }; -} diff --git a/extensions/session-snap/index.ts b/extensions/session-snap/index.ts deleted file mode 100644 index f0e7ee2..0000000 --- a/extensions/session-snap/index.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * session-snap — Session archiver & cleaner for pi - * - * Commands: - * /snap — Scan sessions, classify (delete/archive/keep), review & execute - * /archive — Browse archived sessions, search, restore, delete - */ - -import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; -import { scanAllSessions, formatBytes } from "./scanner.js"; -import { archiveSession, deleteSession, listArchived, restoreSession, deleteArchived, getArchivedCount, closeDb } from "./archive.js"; -import { SweepOverlay, type SweepResult } from "./sweep-overlay.js"; -import { ArchiveOverlay, type ArchiveAction } from "./archive-overlay.js"; - -export default function (pi: ExtensionAPI) { - // ── /snap — Sweep & archive ── - pi.registerCommand("snap", { - description: "Scan sessions, delete trivial ones, archive old ones", - handler: async (_args, ctx: ExtensionCommandContext) => { - if (!ctx.hasUI) { - ctx.ui.notify("session-snap requires interactive mode", "error"); - return; - } - - // Scan all sessions - ctx.ui.setStatus("snap", "⚡ Scanning sessions..."); - - let sessions: ReturnType; - try { - sessions = scanAllSessions((current, total) => { - ctx.ui.setStatus("snap", `⚡ Scanning ${current}/${total}...`); - }); - } finally { - ctx.ui.setStatus("snap", undefined); - } - - if (sessions.length === 0) { - ctx.ui.notify("No sessions found", "info"); - return; - } - - // Exclude current session from the list - const currentSessionFile = ctx.sessionManager.getSessionFile(); - if (currentSessionFile) { - sessions = sessions.filter((s) => s.path !== currentSessionFile); - } - - // Show sweep overlay - const result = await ctx.ui.custom( - (tui, theme, _kb, done) => { - return new SweepOverlay({ - sessions, - theme, - getTermRows: () => tui.terminal?.rows ?? 40, - requestRender: () => tui.requestRender(), - onDone: done, - }); - }, - { - overlay: true, - overlayOptions: { - anchor: "center", - width: "85%", - minWidth: 70, - maxHeight: "90%", - }, - }, - ); - - if (!result) { - ctx.ui.notify("Cancelled", "info"); - return; - } - - // Execute - const { toDelete, toArchive } = result; - - if (toDelete.length === 0 && toArchive.length === 0) { - ctx.ui.notify("Nothing to do", "info"); - return; - } - - // Confirm one more time - const total = toDelete.length + toArchive.length; - const confirmed = await ctx.ui.confirm( - "Execute session-snap?", - `Delete ${toDelete.length} sessions, archive ${toArchive.length} sessions (${total} total)`, - ); - - if (!confirmed) { - ctx.ui.notify("Cancelled", "info"); - return; - } - - ctx.ui.setStatus("snap", "⚡ Processing..."); - - let deleted = 0; - let archived = 0; - let errors = 0; - - try { - // Delete trivial sessions - for (const path of toDelete) { - try { - deleteSession(path); - deleted++; - } catch (e) { - errors++; - } - } - - // Archive old sessions - for (const session of toArchive) { - try { - archiveSession(session); - archived++; - } catch (e) { - errors++; - } - } - } finally { - ctx.ui.setStatus("snap", undefined); - } - - let msg = `⚡ Done! Deleted ${deleted}, archived ${archived}`; - if (errors > 0) msg += `, ${errors} errors`; - ctx.ui.notify(msg, errors > 0 ? "warning" : "info"); - }, - }); - - // ── /archive — Browse archived sessions ── - pi.registerCommand("archive", { - description: "Browse, search, and restore archived sessions", - handler: async (_args, ctx: ExtensionCommandContext) => { - if (!ctx.hasUI) { - ctx.ui.notify("session-snap requires interactive mode", "error"); - return; - } - - const count = getArchivedCount(); - if (count === 0) { - ctx.ui.notify("No archived sessions. Run /snap first.", "info"); - return; - } - - let keepBrowsing = true; - - while (keepBrowsing) { - const initialSessions = listArchived(); - - const action = await ctx.ui.custom( - (tui, theme, _kb, done) => { - return new ArchiveOverlay({ - sessions: initialSessions, - theme, - getTermRows: () => tui.terminal?.rows ?? 40, - getTermCols: () => tui.terminal?.cols ?? 120, - requestRender: () => tui.requestRender(), - onDone: done, - onSearch: (query) => listArchived(query || undefined), - }); - }, - { - overlay: true, - overlayOptions: { - anchor: "center", - width: "90%", - minWidth: 80, - maxHeight: "85%", - }, - }, - ); - - if (action.type === "close") { - keepBrowsing = false; - } else if (action.type === "restore") { - const restoredPath = restoreSession(action.sessionId); - if (restoredPath) { - ctx.ui.notify(`Restored session to ${restoredPath}`, "info"); - } else { - ctx.ui.notify("Failed to restore session", "error"); - } - // Continue browsing after restore - } else if (action.type === "delete") { - deleteArchived(action.sessionId); - ctx.ui.notify("Session permanently deleted", "info"); - // Continue browsing after delete - } - } - }, - }); - - // ── Status: show archive count in footer ── - pi.on("session_start", async (_event, ctx) => { - try { - const count = getArchivedCount(); - if (count > 0) { - ctx.ui.setStatus("snap", `📦 ${count} archived`); - } - } catch { - // DB might not exist yet - } - }); - - // ── Cleanup on shutdown ── - pi.on("session_shutdown", async () => { - closeDb(); - }); -} diff --git a/extensions/session-snap/scanner.ts b/extensions/session-snap/scanner.ts deleted file mode 100644 index e5b020b..0000000 --- a/extensions/session-snap/scanner.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { readFileSync, statSync, readdirSync } from "node:fs"; -import { join } from "node:path"; -import { type SessionMetrics, type SessionCategory, type ClassifiedSession, CONFIG } from "./types.js"; - -const SESSIONS_DIR = `${process.env.HOME}/.pi/agent/sessions`; - -/** Parse a timestamp from session entries — handles both ISO strings and unix ms */ -function parseTimestamp(ts: unknown): number { - if (typeof ts === "number") return ts; - if (typeof ts === "string") return new Date(ts).getTime(); - return 0; -} - -/** Scan a single session JSONL file and compute metrics */ -export function scanSession(filePath: string): SessionMetrics { - const stat = statSync(filePath); - const raw = readFileSync(filePath, "utf-8"); - const lines = raw.split("\n").filter((l) => l.trim()); - - let id = ""; - let cwd = ""; - let name: string | undefined; - let firstMessage = ""; - let firstTimestamp = 0; - let lastTimestamp = 0; - let userMessageCount = 0; - let assistantMessageCount = 0; - - for (const line of lines) { - try { - const entry = JSON.parse(line); - const ts = parseTimestamp(entry.timestamp); - - if (ts > 0) { - if (firstTimestamp === 0 || ts < firstTimestamp) firstTimestamp = ts; - if (ts > lastTimestamp) lastTimestamp = ts; - } - - if (entry.type === "session") { - id = entry.id || ""; - cwd = entry.cwd || ""; - } else if (entry.type === "session_info" && entry.name) { - name = entry.name; - } else if (entry.type === "message" && entry.message) { - const msg = entry.message; - if (msg.role === "user") { - userMessageCount++; - if (!firstMessage) { - firstMessage = extractText(msg.content); - } - } else if (msg.role === "assistant") { - assistantMessageCount++; - } - } - } catch { - // Skip malformed lines - } - } - - const durationSeconds = firstTimestamp > 0 && lastTimestamp > 0 ? Math.round((lastTimestamp - firstTimestamp) / 1000) : 0; - - return { - path: filePath, - id, - cwd, - name, - firstMessage: firstMessage.slice(0, 200), - createdAt: firstTimestamp, - lastActivityAt: lastTimestamp, - durationSeconds, - userMessageCount, - assistantMessageCount, - totalEntries: lines.length, - fileSizeBytes: stat.size, - }; -} - -/** Extract text content from message content field */ -function extractText(content: unknown): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter((b: any) => b.type === "text") - .map((b: any) => b.text) - .join("\n"); - } - return ""; -} - -/** Extract full conversation text (all user + assistant messages) from a JSONL file */ -export function extractFullText(filePath: string): string { - const raw = readFileSync(filePath, "utf-8"); - const lines = raw.split("\n").filter((l) => l.trim()); - const texts: string[] = []; - - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === "message" && entry.message) { - const msg = entry.message; - if (msg.role === "user" || msg.role === "assistant") { - const text = extractText(msg.content); - if (text.trim()) texts.push(text); - } - } - } catch { - // Skip malformed lines - } - } - - return texts.join("\n\n"); -} - -/** Classify a session into delete/archive/keep */ -export function classifySession(metrics: SessionMetrics): SessionCategory { - const durationMinutes = metrics.durationSeconds / 60; - - // DELETE: trivial sessions (short AND few messages) - if (durationMinutes < CONFIG.DELETE_MAX_DURATION_MINUTES && metrics.userMessageCount < CONFIG.DELETE_MAX_USER_MESSAGES) { - return "delete"; - } - - // ARCHIVE: old sessions (last activity > threshold days ago) - const ageMs = Date.now() - metrics.lastActivityAt; - const ageDays = ageMs / (1000 * 60 * 60 * 24); - if (ageDays > CONFIG.ARCHIVE_MIN_AGE_DAYS) { - return "archive"; - } - - // KEEP: everything else - return "keep"; -} - -/** Scan all sessions across all project directories */ -export function scanAllSessions(onProgress?: (current: number, total: number) => void): ClassifiedSession[] { - const results: ClassifiedSession[] = []; - const allFiles: string[] = []; - - // Collect all JSONL files - try { - const dirs = readdirSync(SESSIONS_DIR); - for (const dir of dirs) { - const dirPath = join(SESSIONS_DIR, dir); - try { - const files = readdirSync(dirPath); - for (const file of files) { - if (file.endsWith(".jsonl")) { - allFiles.push(join(dirPath, file)); - } - } - } catch { - // Not a directory or not readable - } - } - } catch { - return []; - } - - // Scan each file - for (let i = 0; i < allFiles.length; i++) { - onProgress?.(i + 1, allFiles.length); - try { - const metrics = scanSession(allFiles[i]); - const category = classifySession(metrics); - results.push({ ...metrics, category }); - } catch { - // Skip unreadable sessions - } - } - - return results; -} - -/** Format bytes to human readable */ -export function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -/** Format duration to human readable */ -export function formatDuration(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - return m > 0 ? `${h}h ${m}m` : `${h}h`; -} - -/** Extract short project name from CWD */ -export function projectName(cwd: string): string { - if (!cwd) return "???"; - const parts = cwd.split("/"); - return parts[parts.length - 1] || parts[parts.length - 2] || cwd; -} diff --git a/extensions/session-snap/sweep-overlay.ts b/extensions/session-snap/sweep-overlay.ts deleted file mode 100644 index 5b3287b..0000000 --- a/extensions/session-snap/sweep-overlay.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * Sweep overlay — TUI for reviewing and confirming session cleanup. - * - * Layout: - * ┌─────────────────────────────────────────────────────────┐ - * │ ⚡ Session Snap — Scan Results │ - * │ │ - * │ 🗑️ DELETE 82 sessions 3.2 MB │ - * │ 📦 ARCHIVE 180 sessions 58.4 MB │ - * │ ✅ KEEP 284 sessions 52.4 MB │ - * │ │ - * │ ─── DELETE (82) ────────────────────────────────────── │ - * │ > ✕ 2026-02-21 pi-ext /exit only 0.3 KB │ - * │ ✕ 2026-02-22 skald "how to.." 1msg 1.2 KB │ - * │ ✓ 2026-02-22 lgtm-k8s "fix dep.." 2msg 4.1 KB │ - * │ │ - * │ Tab: switch category Space: toggle Enter: execute │ - * │ a: select all n: deselect all Esc: cancel │ - * └─────────────────────────────────────────────────────────┘ - */ - -import type { Component, Focusable } from "@mariozechner/pi-tui"; -import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import type { ClassifiedSession, SessionCategory } from "./types.js"; -import { formatBytes, formatDuration, projectName } from "./scanner.js"; - -export interface SweepResult { - toDelete: string[]; - toArchive: ClassifiedSession[]; -} - -interface SweepOverlayOptions { - sessions: ClassifiedSession[]; - theme: any; - getTermRows: () => number; - requestRender: () => void; - onDone: (result: SweepResult | null) => void; -} - -type TabId = "delete" | "archive" | "keep"; -const TABS: TabId[] = ["delete", "archive", "keep"]; -const TAB_ICONS: Record = { delete: "🗑️ ", archive: "📦", keep: "✅" }; -const TAB_LABELS: Record = { delete: "DELETE", archive: "ARCHIVE", keep: "KEEP" }; - -interface SessionRow { - session: ClassifiedSession; - selected: boolean; -} - -export class SweepOverlay implements Component, Focusable { - focused = false; - private activeTab: TabId = "delete"; - private scrollOffset = 0; - private cursorIndex = 0; - private rows: Map; - private theme: any; - private getTermRows: () => number; - private requestRender: () => void; - private onDone: (result: SweepResult | null) => void; - private allSessions: ClassifiedSession[]; - - constructor(opts: SweepOverlayOptions) { - this.theme = opts.theme; - this.getTermRows = opts.getTermRows; - this.requestRender = opts.requestRender; - this.onDone = opts.onDone; - this.allSessions = opts.sessions; - - // Group sessions by category - this.rows = new Map(); - for (const tab of TABS) { - const filtered = opts.sessions - .filter((s) => s.category === tab) - .sort((a, b) => a.lastActivityAt - b.lastActivityAt); - this.rows.set( - tab, - filtered.map((s) => ({ - session: s, - // DELETE and ARCHIVE default to selected, KEEP defaults to unselected - selected: tab !== "keep", - })), - ); - } - } - - private getCurrentRows(): SessionRow[] { - return this.rows.get(this.activeTab) ?? []; - } - - handleInput(data: string): void { - const rows = this.getCurrentRows(); - - if (data === "\x1b" || data === "\x1b[") { - // Escape → cancel - this.onDone(null); - return; - } - - if (data === "\t") { - // Tab → switch category - const idx = TABS.indexOf(this.activeTab); - this.activeTab = TABS[(idx + 1) % TABS.length]; - this.cursorIndex = 0; - this.scrollOffset = 0; - this.requestRender(); - return; - } - - if (data === "\x1b[Z") { - // Shift-Tab → switch category backwards - const idx = TABS.indexOf(this.activeTab); - this.activeTab = TABS[(idx - 1 + TABS.length) % TABS.length]; - this.cursorIndex = 0; - this.scrollOffset = 0; - this.requestRender(); - return; - } - - if (data === "\x1b[A" || data === "k") { - // Up - if (this.cursorIndex > 0) { - this.cursorIndex--; - if (this.cursorIndex < this.scrollOffset) { - this.scrollOffset = this.cursorIndex; - } - } - this.requestRender(); - return; - } - - if (data === "\x1b[B" || data === "j") { - // Down - if (this.cursorIndex < rows.length - 1) { - this.cursorIndex++; - const visibleHeight = this.getVisibleHeight(); - if (this.cursorIndex >= this.scrollOffset + visibleHeight) { - this.scrollOffset = this.cursorIndex - visibleHeight + 1; - } - } - this.requestRender(); - return; - } - - if (data === " ") { - // Space → toggle selection - if (rows[this.cursorIndex]) { - rows[this.cursorIndex].selected = !rows[this.cursorIndex].selected; - } - this.requestRender(); - return; - } - - if (data === "a") { - // Select all in current tab - for (const row of rows) row.selected = true; - this.requestRender(); - return; - } - - if (data === "n") { - // Deselect all in current tab - for (const row of rows) row.selected = false; - this.requestRender(); - return; - } - - if (data === "\r" || data === "\n") { - // Enter → confirm and execute - const toDelete: string[] = []; - const toArchive: ClassifiedSession[] = []; - - for (const row of this.rows.get("delete") ?? []) { - if (row.selected) toDelete.push(row.session.path); - } - for (const row of this.rows.get("archive") ?? []) { - if (row.selected) toArchive.push(row.session); - } - - this.onDone({ toDelete, toArchive }); - return; - } - } - - private getVisibleHeight(): number { - // Leave room for header (8 lines) + footer (3 lines) + borders - return Math.max(5, this.getTermRows() - 16); - } - - render(width: number): string[] { - const th = this.theme; - const lines: string[] = []; - const innerW = width - 4; - - // ── Title ── - lines.push(""); - lines.push(" " + th.bold(th.fg("accent", "⚡ Session Snap"))); - lines.push(""); - - // ── Summary stats ── - for (const tab of TABS) { - const tabRows = this.rows.get(tab) ?? []; - const selected = tabRows.filter((r) => r.selected).length; - const totalSize = tabRows.reduce((sum, r) => sum + r.session.fileSizeBytes, 0); - const selectedSize = tabRows.filter((r) => r.selected).reduce((sum, r) => sum + r.session.fileSizeBytes, 0); - - const icon = TAB_ICONS[tab]; - const label = TAB_LABELS[tab]; - const isActive = this.activeTab === tab; - - let line = ` ${icon} `; - if (isActive) { - line += th.bold(th.fg("accent", padRight(label, 9))); - } else { - line += th.fg("dim", padRight(label, 9)); - } - line += th.fg("muted", `${padLeft(String(selected), 4)}/${tabRows.length} sessions`); - line += th.fg("dim", ` ${formatBytes(selectedSize)}`); - - lines.push(line); - } - - lines.push(""); - - // ── Tab header ── - const tabRows = this.getCurrentRows(); - const selectedCount = tabRows.filter((r) => r.selected).length; - const tabTitle = `── ${TAB_LABELS[this.activeTab]} (${selectedCount}/${tabRows.length}) `; - lines.push(" " + th.fg("border", tabTitle + "─".repeat(Math.max(0, innerW - visibleWidth(tabTitle))))); - - // ── Session list ── - if (tabRows.length === 0) { - lines.push(""); - lines.push(" " + th.fg("dim", "(no sessions in this category)")); - } else { - const visibleH = this.getVisibleHeight(); - const end = Math.min(tabRows.length, this.scrollOffset + visibleH); - - for (let i = this.scrollOffset; i < end; i++) { - const row = tabRows[i]; - const isCursor = i === this.cursorIndex; - const s = row.session; - - const checkbox = row.selected ? th.fg("success", "✓") : th.fg("dim", "·"); - const cursor = isCursor ? th.fg("accent", "▸") : " "; - const date = new Date(s.createdAt).toISOString().slice(0, 10); - const proj = padRight(projectName(s.cwd), 14); - const msgs = `${s.userMessageCount}msg`; - const dur = formatDuration(s.durationSeconds); - const size = formatBytes(s.fileSizeBytes); - const preview = truncateToWidth(s.firstMessage.replace(/\n/g, " ") || "(empty)", Math.max(10, innerW - 60)); - - let line = ` ${cursor} ${checkbox} ${th.fg("dim", date)} `; - if (isCursor) { - line += th.fg("accent", proj); - } else { - line += th.fg("muted", proj); - } - line += th.fg("dim", ` ${padLeft(dur, 5)} ${padLeft(msgs, 5)} ${padLeft(size, 8)} `); - line += th.fg("dim", `"${preview}"`); - - lines.push(truncateToWidth(line, width)); - } - - // Scroll indicator - if (tabRows.length > visibleH) { - const pos = Math.round((this.scrollOffset / (tabRows.length - visibleH)) * 100); - lines.push(" " + th.fg("dim", ` ↕ ${this.scrollOffset + 1}-${end}/${tabRows.length} (${pos}%)`)); - } - } - - // ── Footer ── - lines.push(""); - lines.push( - " " + - th.fg("dim", "Tab") + - th.fg("muted", ": category ") + - th.fg("dim", "Space") + - th.fg("muted", ": toggle ") + - th.fg("dim", "a") + - th.fg("muted", "/") + - th.fg("dim", "n") + - th.fg("muted", ": all/none ") + - th.fg("dim", "Enter") + - th.fg("muted", ": execute ") + - th.fg("dim", "Esc") + - th.fg("muted", ": cancel"), - ); - lines.push(""); - - return lines; - } - - invalidate(): void {} -} - -function padRight(s: string, w: number): string { - return s.length >= w ? s.slice(0, w) : s + " ".repeat(w - s.length); -} - -function padLeft(s: string, w: number): string { - return s.length >= w ? s : " ".repeat(w - s.length) + s; -} diff --git a/extensions/session-snap/types.ts b/extensions/session-snap/types.ts deleted file mode 100644 index ddf55ae..0000000 --- a/extensions/session-snap/types.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** Metrics computed by scanning a session JSONL file */ -export interface SessionMetrics { - /** Original session file path */ - path: string; - /** Session UUID */ - id: string; - /** Working directory */ - cwd: string; - /** User-defined session name */ - name?: string; - /** First user message text (for preview) */ - firstMessage: string; - /** First entry timestamp (ms) */ - createdAt: number; - /** Last entry timestamp (ms) */ - lastActivityAt: number; - /** Duration in seconds */ - durationSeconds: number; - /** Number of user messages */ - userMessageCount: number; - /** Number of assistant messages */ - assistantMessageCount: number; - /** Total JSONL entries */ - totalEntries: number; - /** File size in bytes */ - fileSizeBytes: number; -} - -/** Classification category for a session */ -export type SessionCategory = "delete" | "archive" | "keep"; - -/** A session with its computed category */ -export interface ClassifiedSession extends SessionMetrics { - category: SessionCategory; -} - -/** Archived session record stored in SQLite */ -export interface ArchivedSession { - id: string; - originalPath: string; - cwd: string; - name: string | null; - firstMessage: string; - createdAt: number; - lastActivityAt: number; - durationSeconds: number; - userMessageCount: number; - assistantMessageCount: number; - totalEntries: number; - fileSizeBytes: number; - archivedAt: number; - fullText: string; -} - -/** Configuration thresholds */ -export const CONFIG = { - /** Sessions shorter than this AND with fewer messages than DELETE_MAX_USER_MESSAGES → delete */ - DELETE_MAX_DURATION_MINUTES: 5, - /** Sessions with fewer user messages than this AND shorter than DELETE_MAX_DURATION_MINUTES → delete */ - DELETE_MAX_USER_MESSAGES: 3, - /** Sessions older than this (days since last activity) → archive */ - ARCHIVE_MIN_AGE_DAYS: 14, - /** SQLite database path */ - DB_PATH: `${process.env.HOME}/.pi/agent/session-archive.db`, -} as const; -- 2.51.2 From cbf6e7bd6d7bdb32bb6f3c36a456ffffe819b1ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:30:58 +0200 Subject: [PATCH 27/66] chore(skills): drop unused python helper scripts --- skills/visit-webpage/visit.py | 165 ---------------------------------- skills/web-search/search.py | 68 -------------- 2 files changed, 233 deletions(-) delete mode 100755 skills/visit-webpage/visit.py delete mode 100755 skills/web-search/search.py diff --git a/skills/visit-webpage/visit.py b/skills/visit-webpage/visit.py deleted file mode 100755 index 641efc2..0000000 --- a/skills/visit-webpage/visit.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -"""Visit webpage and extract content as markdown, or download images.""" - -import os -import re -import sys -import tempfile -import time -import urllib.request -from urllib.error import HTTPError, URLError - -MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB -MAX_CONTENT_LENGTH = 100000 # 100KB text limit -TIMEOUT = 60 -RETRY_DELAYS = [0, 30, 90] - -IMAGE_EXTENSIONS = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/gif": ".gif", - "image/webp": ".webp", -} - - -def get_headers(include_jina_auth: bool = False) -> dict[str, str]: - """Build request headers.""" - headers = {"User-Agent": "pi-skill/1.0"} - api_key = os.environ.get("JINA_API_KEY") - if include_jina_auth and api_key: - headers["Authorization"] = f"Bearer {api_key}" - return headers - - -def check_content_type(url: str) -> str | None: - """Check URL content type via HEAD request. Returns content-type or None on error.""" - req = urllib.request.Request(url, method="HEAD", headers=get_headers()) - try: - with urllib.request.urlopen(req, timeout=TIMEOUT) as response: - return response.headers.get("Content-Type", "").lower().split(";")[0] - except (HTTPError, URLError): - return None - - -def download_image(url: str) -> str: - """Download image and save to temp file. Returns file path.""" - req = urllib.request.Request(url, headers=get_headers()) - - with urllib.request.urlopen(req, timeout=TIMEOUT) as response: - content_type = response.headers.get("Content-Type", "").lower().split(";")[0] - - if content_type not in IMAGE_EXTENSIONS: - raise ValueError(f"Unsupported image type: {content_type}") - - content_length = response.headers.get("Content-Length") - if content_length and int(content_length) > MAX_IMAGE_SIZE: - raise ValueError(f"Image too large: {content_length} bytes (max {MAX_IMAGE_SIZE})") - - data = response.read() - if len(data) > MAX_IMAGE_SIZE: - raise ValueError(f"Image too large: {len(data)} bytes (max {MAX_IMAGE_SIZE})") - - ext = IMAGE_EXTENSIONS[content_type] - - # Create temp file with appropriate extension - fd, filepath = tempfile.mkstemp(suffix=ext, prefix="visit-image-") - os.write(fd, data) - os.close(fd) - - return filepath - - -def fetch_webpage(url: str) -> str: - """Fetch webpage content via Jina Reader with retries.""" - jina_url = f"https://r.jina.ai/{url}" - headers = get_headers(include_jina_auth=True) - - last_error = None - for i, delay in enumerate(RETRY_DELAYS): - if delay > 0: - print(f"Waiting {delay}s before retry {i+1}/{len(RETRY_DELAYS)}...", file=sys.stderr) - time.sleep(delay) - - req = urllib.request.Request(jina_url, headers=headers) - try: - with urllib.request.urlopen(req, timeout=TIMEOUT) as response: - content = response.read().decode("utf-8", errors="replace") - - # Clean up multiple line breaks - content = re.sub(r"\n{3,}", "\n\n", content) - - # Truncate if too long - if len(content) > MAX_CONTENT_LENGTH: - content = content[:MAX_CONTENT_LENGTH] + "\n\n..._Content truncated_..." - - return content - - except HTTPError as e: - last_error = e - if e.code in (451, 500, 502, 503, 504) and i < len(RETRY_DELAYS) - 1: - print(f"HTTP {e.code}, will retry...", file=sys.stderr) - continue - raise - except URLError as e: - last_error = e - if i < len(RETRY_DELAYS) - 1: - print(f"Network error: {e.reason}, will retry...", file=sys.stderr) - continue - raise - - raise last_error or RuntimeError("Failed after retries") - - -def main(): - if len(sys.argv) < 2: - print("Usage: visit.py ") - print() - print("Fetches a webpage and extracts its content as markdown,") - print("or downloads images to a temp file.") - print() - print("Environment:") - print(" JINA_API_KEY Optional. Your Jina API key for higher rate limits.") - print() - print("Examples:") - print(" visit.py https://example.com/article") - print(" visit.py https://example.com/image.png") - sys.exit(1) - - url = sys.argv[1] - - # Validate URL - if not url.startswith(("http://", "https://")): - print("Error: URL must start with http:// or https://", file=sys.stderr) - sys.exit(1) - - try: - # Check content type first - content_type = check_content_type(url) - - if content_type and content_type.startswith("image/"): - # Handle image - download to temp and print path (like browser-screenshot.js) - filepath = download_image(url) - print(filepath) - else: - # Handle webpage - content = fetch_webpage(url) - print(f"## Content from {url}") - print() - print(content) - - except HTTPError as e: - print(f"Error: HTTP {e.code} - {e.reason}", file=sys.stderr) - sys.exit(1) - except URLError as e: - print(f"Error: {e.reason}", file=sys.stderr) - sys.exit(1) - except ValueError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/skills/web-search/search.py b/skills/web-search/search.py deleted file mode 100755 index c7a3ed8..0000000 --- a/skills/web-search/search.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -"""Web search using Jina Search API.""" - -import os -import sys -import urllib.parse -import urllib.request -from urllib.error import HTTPError, URLError - -TIMEOUT = 30 - - -def main(): - if len(sys.argv) < 2: - print("Usage: search.py ") - print() - print("Searches the web using Jina Search API.") - print() - print("Environment:") - print(" JINA_API_KEY Optional. Your Jina API key for higher rate limits.") - print() - print("Examples:") - print(' search.py "python async await"') - print(' search.py "rust ownership tutorial"') - sys.exit(1) - - query = " ".join(sys.argv[1:]) - encoded_query = urllib.parse.quote(query) - url = f"https://s.jina.ai/?q={encoded_query}" - - # Build headers - headers = { - "User-Agent": "pi-skill/1.0", - "X-Respond-With": "no-content", - "Accept": "text/plain", - } - - api_key = os.environ.get("JINA_API_KEY") - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - - req = urllib.request.Request(url, headers=headers) - - try: - with urllib.request.urlopen(req, timeout=TIMEOUT) as response: - content = response.read().decode("utf-8", errors="replace").strip() - - if not content: - print("No search results found. Try a different query.") - sys.exit(0) - - print("## Search Results") - print() - print(content) - - except HTTPError as e: - print(f"Error: HTTP {e.code} - {e.reason}", file=sys.stderr) - sys.exit(1) - except URLError as e: - print(f"Error: {e.reason}", file=sys.stderr) - sys.exit(1) - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() -- 2.51.2 From a02b245beb20438483b6ed8790fdc5758a204086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:31:03 +0200 Subject: [PATCH 28/66] feat(leader-key): rework favourite-models and session tree types --- extensions/leader-key/context-helpers.ts | 4 +- extensions/leader-key/favourite-models.json | 44 --- extensions/leader-key/favourite-models.ts | 362 +++++++++++++++----- extensions/leader-key/index.ts | 46 ++- extensions/leader-key/label-actions.ts | 4 +- 5 files changed, 330 insertions(+), 130 deletions(-) delete mode 100644 extensions/leader-key/favourite-models.json diff --git a/extensions/leader-key/context-helpers.ts b/extensions/leader-key/context-helpers.ts index ba8d63e..2833f63 100644 --- a/extensions/leader-key/context-helpers.ts +++ b/extensions/leader-key/context-helpers.ts @@ -11,8 +11,10 @@ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, + SessionManager, } from "@mariozechner/pi-coding-agent"; -import type { SessionTreeNode } from "@mariozechner/pi-coding-agent/dist/core/session-manager.js"; + +type SessionTreeNode = ReturnType[number]; /** Check if ctx has full command context capabilities. */ function isCommandCtx(ctx: ExtensionContext): ctx is ExtensionCommandContext { diff --git a/extensions/leader-key/favourite-models.json b/extensions/leader-key/favourite-models.json deleted file mode 100644 index 088413a..0000000 --- a/extensions/leader-key/favourite-models.json +++ /dev/null @@ -1,44 +0,0 @@ -[ - { - "label": "Opus 4.6", - "provider": "anthropic", - "model": "claude-opus-4-6", - "thinking": "medium" - }, - { - "label": "Sonnet 4.6", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - "thinking": "medium" - }, - { - "label": "Opus 4.6", - "provider": "github-copilot", - "model": "claude-opus-4.6", - "thinking": "medium" - }, - { - "label": "Sonnet 4.6", - "provider": "github-copilot", - "model": "claude-sonnet-4.6", - "thinking": "medium" - }, - { - "label": "GPT-5.4", - "provider": "github-copilot", - "model": "gpt-5.4", - "thinking": "medium" - }, - { - "label": "GPT-5.4", - "provider": "openai-codex", - "model": "gpt-5.4", - "thinking": "medium" - }, - { - "label": "Opus 4.7", - "provider": "anthropic", - "model": "claude-opus-4-7", - "thinking": "medium" - } -] diff --git a/extensions/leader-key/favourite-models.ts b/extensions/leader-key/favourite-models.ts index 60b33fb..b56587d 100644 --- a/extensions/leader-key/favourite-models.ts +++ b/extensions/leader-key/favourite-models.ts @@ -1,67 +1,234 @@ /** - * Favourite Models Picker + * Scoped Models Picker * - * Quick-switch to a favourite model with inline thinking level. - * Favourites are configured in favourite-models.json (max 8 entries). + * Legacy file name kept for leader-key wiring, but the picker no longer reads + * favourite-models.json. Entries now come from Pi's enabledModels scope plus + * the current model registry. * - * Each entry specifies: - * - label: display name shown in the picker - * - provider: provider name (e.g. "anthropic", "google", "openai") - * - model: model id - * - thinking: (optional) default thinking level + * Behavior: + * - If enabledModels is configured, resolve those patterns in order. + * - If enabledModels is empty, fall back to all available models. + * - Pattern-level thinking suffixes like "provider/model:high" are honored. */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { SettingsManager } from "@mariozechner/pi-coding-agent"; import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; -import { matchesKey, Key } from "@mariozechner/pi-tui"; +import type { Model } from "@mariozechner/pi-ai"; +import { matchesKey, parseKey, Key } from "@mariozechner/pi-tui"; import { OverlayFrame } from "../shared/overlay.js"; -import { readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { ALL_THINKING_LEVELS } from "./model-switcher"; +import { ALL_THINKING_LEVELS } from "./model-switcher.js"; import { THINKING_ROLES } from "../shared/thinking-colors.js"; -// ───────────────────────────────────────────────────────────────────────────── -// Config types -// ───────────────────────────────────────────────────────────────────────────── - interface FavouriteModelEntry { label: string; provider: string; model: string; - thinking?: string; + thinking?: ThinkingLevel; } -const MAX_FAVOURITES = 8; +interface ModelRow { + type: "model"; + fav: FavouriteModelEntry; + modelIndex: number; +} -// ───────────────────────────────────────────────────────────────────────────── -// Load config -// ───────────────────────────────────────────────────────────────────────────── +interface HeaderRow { + type: "header"; + provider: string; + count: number; +} -function loadFavourites(): FavouriteModelEntry[] { - const configPath = join(dirname(new URL(import.meta.url).pathname), "favourite-models.json"); - try { - const raw = readFileSync(configPath, "utf-8"); - const parsed = JSON.parse(raw) as FavouriteModelEntry[]; - if (!Array.isArray(parsed)) { - return []; +type DisplayRow = ModelRow | HeaderRow; + +function buildDisplayRows(favourites: FavouriteModelEntry[]): DisplayRow[] { + const sorted = favourites + .map((f, i) => ({ ...f, originalIndex: i })) + .sort((a, b) => { + const pc = a.provider.localeCompare(b.provider); + if (pc !== 0) return pc; + return a.label.localeCompare(b.label); + }); + + const rows: DisplayRow[] = []; + let lastProvider = ""; + let providerCount = 0; + let providerStart = 0; + + for (let i = 0; i < sorted.length; i++) { + const item = sorted[i]; + if (item.provider !== lastProvider) { + if (lastProvider) { + rows[providerStart] = { type: "header", provider: lastProvider, count: providerCount }; + } + rows.push({ type: "header", provider: item.provider, count: 0 }); + providerStart = rows.length - 1; + providerCount = 0; + lastProvider = item.provider; } - // Enforce max and validate - return parsed - .filter( - (e) => - typeof e.label === "string" && - typeof e.provider === "string" && - typeof e.model === "string", - ) - .slice(0, MAX_FAVOURITES); - } catch { - return []; + rows.push({ type: "model", fav: item, modelIndex: item.originalIndex }); + providerCount++; + } + if (lastProvider) { + rows[providerStart] = { type: "header", provider: lastProvider, count: providerCount }; + } + + return rows; +} + +function nextModelRow(rows: DisplayRow[], from: number, dir: -1 | 1): number { + let i = from + dir; + while (i >= 0 && i < rows.length) { + if (rows[i].type === "model") return i; + i += dir; + } + return from; +} + +function nthModelRow(rows: DisplayRow[], n: number): number { + let count = 0; + for (let i = 0; i < rows.length; i++) { + if (rows[i].type === "model") { + if (count === n) return i; + count++; + } + } + return -1; +} + +const THINKING_SHORTCUTS: Record = { + o: "off", + i: "minimal", + l: "low", + m: "medium", + h: "high", + x: "xhigh", +}; + +function getPrintableKey(data: string): string | null { + const parsed = parseKey(data); + if (parsed && parsed.length === 1 && parsed >= " " && parsed <= "~") { + return parsed.toLowerCase(); } + if (data.length === 1 && data >= " " && data <= "~") { + return data.toLowerCase(); + } + return null; +} + +function isThinkingLevel(value: string | undefined): value is ThinkingLevel { + return !!value && ALL_THINKING_LEVELS.includes(value as ThinkingLevel); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function globToRegExp(pattern: string): RegExp { + return new RegExp( + "^" + escapeRegExp(pattern).replace(/\\\*/g, ".*").replace(/\\\?/g, ".") + "$", + "i", + ); +} + +function findExactModelReferenceMatch( + modelReference: string, + availableModels: Model[], +): Model | undefined { + const slashIndex = modelReference.indexOf("/"); + if (slashIndex !== -1) { + const provider = modelReference.substring(0, slashIndex).toLowerCase(); + const modelId = modelReference.substring(slashIndex + 1).toLowerCase(); + return availableModels.find( + (model) => model.provider.toLowerCase() === provider && model.id.toLowerCase() === modelId, + ); + } + + const matches = availableModels.filter((model) => model.id.toLowerCase() === modelReference.toLowerCase()); + return matches.length === 1 ? matches[0] : undefined; +} + +function sortAllAvailableModels( + availableModels: Model[], + currentModel: Model | undefined, +): Model[] { + return [...availableModels].sort((a, b) => { + const aIsCurrent = currentModel?.provider === a.provider && currentModel?.id === a.id; + const bIsCurrent = currentModel?.provider === b.provider && currentModel?.id === b.id; + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + + const providerCmp = a.provider.localeCompare(b.provider); + if (providerCmp !== 0) return providerCmp; + return a.name.localeCompare(b.name); + }); +} + +function toEntry(model: Model, thinking?: ThinkingLevel): FavouriteModelEntry { + return { + label: model.name, + provider: model.provider, + model: model.id, + thinking, + }; } -// ───────────────────────────────────────────────────────────────────────────── -// Favourite Models Picker UI -// ───────────────────────────────────────────────────────────────────────────── +function loadScopedModels(ctx: ExtensionContext): FavouriteModelEntry[] { + const settings = SettingsManager.create(ctx.cwd); + const patterns = settings.getEnabledModels(); + const availableModels = ctx.modelRegistry.getAvailable(); + + if (!patterns || patterns.length === 0) { + return sortAllAvailableModels(availableModels, ctx.model).map((model) => toEntry(model)); + } + + const resolved: FavouriteModelEntry[] = []; + const seen = new Set(); + + const pushModel = (model: Model, thinking?: ThinkingLevel) => { + const key = `${model.provider}/${model.id}`.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + resolved.push(toEntry(model, thinking)); + }; + + for (const rawPattern of patterns) { + const exactFullMatch = findExactModelReferenceMatch(rawPattern, availableModels); + if (exactFullMatch) { + pushModel(exactFullMatch); + continue; + } + + let pattern = rawPattern; + let thinking: ThinkingLevel | undefined; + const colonIndex = rawPattern.lastIndexOf(":"); + if (colonIndex !== -1) { + const maybeThinking = rawPattern.substring(colonIndex + 1); + if (isThinkingLevel(maybeThinking)) { + thinking = maybeThinking; + pattern = rawPattern.substring(0, colonIndex); + } + } + + const exactMatch = findExactModelReferenceMatch(pattern, availableModels); + if (exactMatch) { + pushModel(exactMatch, thinking); + continue; + } + + const matcher = globToRegExp(pattern); + const matchingModels = availableModels.filter((model) => { + const fullId = `${model.provider}/${model.id}`; + return matcher.test(fullId) || matcher.test(model.id); + }); + + for (const model of matchingModels) { + pushModel(model, thinking); + } + } + + return resolved; +} interface PickerResult { fav: FavouriteModelEntry; @@ -71,38 +238,56 @@ interface PickerResult { export async function runFavouriteModels(pi: ExtensionAPI, ctx: ExtensionContext): Promise { if (!ctx.hasUI) return; - const favourites = loadFavourites(); + const favourites = loadScopedModels(ctx); if (favourites.length === 0) { - ctx.ui.notify("No favourite models configured. Edit favourite-models.json", "warning"); + ctx.ui.notify("No scoped models available. Use /scoped-models or configure API keys", "warning"); return; } const currentModel = ctx.model; const currentThinking = pi.getThinkingLevel(); - // Build per-entry thinking indices const thinkingIndices: number[] = favourites.map((fav) => { - const idx = fav.thinking ? ALL_THINKING_LEVELS.indexOf(fav.thinking as ThinkingLevel) : -1; + const idx = fav.thinking ? ALL_THINKING_LEVELS.indexOf(fav.thinking) : -1; return idx >= 0 ? idx : ALL_THINKING_LEVELS.indexOf(currentThinking); }); + const rows = buildDisplayRows(favourites); + const firstModelRow = rows.findIndex((r) => r.type === "model"); + const selected = await ctx.ui.custom( (tui, theme, _kb, done) => { - let highlightedIndex = 0; + let highlightedIndex = firstModelRow >= 0 ? firstModelRow : 0; const th = theme; + const getModelIndex = (): number => { + const row = rows[highlightedIndex]; + return row?.type === "model" ? row.modelIndex : -1; + }; + return { render: (width: number) => { const f = new OverlayFrame(width, th); const lines: string[] = []; lines.push(f.top()); - lines.push(f.row(th.fg("accent", th.bold("Favourite Models")))); + lines.push(f.row(th.fg("accent", th.bold("Scoped Models")))); lines.push(f.separator()); - for (let i = 0; i < favourites.length; i++) { - const fav = favourites[i]; + let modelCounter = 0; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + + if (row.type === "header") { + const headerText = ` ${row.provider} ${row.count > 0 ? `(${row.count})` : ""} `; + const padded = headerText.padEnd(width - 4, "─"); + lines.push(f.row(th.fg("dim", `──${padded}`))); + continue; + } + + const fav = row.fav; const isHighlighted = i === highlightedIndex; + const mi = row.modelIndex; const isCurrent = currentModel?.provider === fav.provider && @@ -112,22 +297,23 @@ export async function runFavouriteModels(pi: ExtensionAPI, ctx: ExtensionContext ? th.fg("accent", th.bold(fav.label)) : th.fg("text", fav.label); - const providerTag = th.fg("dim", `(${fav.provider})`); const currentBadge = isCurrent ? " " + th.fg("success", "●") : ""; - const thinking = ALL_THINKING_LEVELS[thinkingIndices[i]]; + const thinking = ALL_THINKING_LEVELS[thinkingIndices[mi]]; const thinkingRole = THINKING_ROLES[thinking] ?? "dim"; const thinkingTag = isHighlighted ? th.fg("dim", "‹") + th.fg(thinkingRole, ` ${thinking} `) + th.fg("dim", "›") : th.fg(thinkingRole, thinking); - const num = th.fg("dim", `${i + 1}`); - const line = `${isHighlighted ? "> " : " "}${num} ${label} ${providerTag}${currentBadge} ${thinkingTag}`; + const num = modelCounter < 9 ? th.fg("dim", `${modelCounter + 1}`) : th.fg("dim", "·"); + const line = `${isHighlighted ? "> " : " "}${num} ${label}${currentBadge} ${thinkingTag}`; lines.push(f.rowTruncated(line)); + modelCounter++; } lines.push(f.separator()); - lines.push(f.row(th.fg("dim", "j/k navigate | 1-8 jump | h/l thinking | enter select | esc cancel"))); + lines.push(f.row(th.fg("dim", "j/k navigate | 1-9 jump | left/right cycle thinking"))); + lines.push(f.row(th.fg("dim", "o/i/l/m/h/x set thinking | enter select | esc cancel"))); lines.push(f.bottom()); return lines; @@ -144,47 +330,67 @@ export async function runFavouriteModels(pi: ExtensionAPI, ctx: ExtensionContext return; } - // Navigate: arrows + vim j/k - if (matchesKey(data, "up") || matchesKey(data, Key.ctrl("p")) || data === "k") { - highlightedIndex = Math.max(0, highlightedIndex - 1); + const key = getPrintableKey(data); + + if (matchesKey(data, "up") || matchesKey(data, Key.ctrl("p")) || key === "k") { + highlightedIndex = nextModelRow(rows, highlightedIndex, -1); tui.requestRender(); return; } - if (matchesKey(data, "down") || matchesKey(data, Key.ctrl("n")) || data === "j") { - highlightedIndex = Math.min(favourites.length - 1, highlightedIndex + 1); + if (matchesKey(data, "down") || matchesKey(data, Key.ctrl("n")) || key === "j") { + highlightedIndex = nextModelRow(rows, highlightedIndex, 1); tui.requestRender(); return; } - // Thinking: arrows + vim h/l - if (matchesKey(data, "left") || data === "h") { - thinkingIndices[highlightedIndex] = (thinkingIndices[highlightedIndex] - 1 + ALL_THINKING_LEVELS.length) % ALL_THINKING_LEVELS.length; - tui.requestRender(); + if (matchesKey(data, "left")) { + const mi = getModelIndex(); + if (mi >= 0) { + thinkingIndices[mi] = (thinkingIndices[mi] - 1 + ALL_THINKING_LEVELS.length) % ALL_THINKING_LEVELS.length; + tui.requestRender(); + } return; } - if (matchesKey(data, "right") || data === "l") { - thinkingIndices[highlightedIndex] = (thinkingIndices[highlightedIndex] + 1) % ALL_THINKING_LEVELS.length; - tui.requestRender(); + if (matchesKey(data, "right")) { + const mi = getModelIndex(); + if (mi >= 0) { + thinkingIndices[mi] = (thinkingIndices[mi] + 1) % ALL_THINKING_LEVELS.length; + tui.requestRender(); + } return; } - // Number keys 1-8: jump to entry - const num = parseInt(data, 10); - if (num >= 1 && num <= favourites.length) { - highlightedIndex = num - 1; - tui.requestRender(); - return; + if (key) { + const num = parseInt(key, 10); + const modelCount = rows.filter((r) => r.type === "model").length; + if (num >= 1 && num <= Math.min(9, modelCount)) { + const target = nthModelRow(rows, num - 1); + if (target >= 0) highlightedIndex = target; + tui.requestRender(); + return; + } + } + + if (key) { + const shortcutThinking = THINKING_SHORTCUTS[key]; + if (shortcutThinking) { + const mi = getModelIndex(); + if (mi >= 0) { + thinkingIndices[mi] = ALL_THINKING_LEVELS.indexOf(shortcutThinking); + tui.requestRender(); + } + return; + } } - // Enter: select highlighted if (matchesKey(data, "enter")) { - if (highlightedIndex >= 0 && highlightedIndex < favourites.length) { + const row = rows[highlightedIndex]; + if (row?.type === "model") { done({ - fav: favourites[highlightedIndex], - thinking: ALL_THINKING_LEVELS[thinkingIndices[highlightedIndex]], + fav: row.fav, + thinking: ALL_THINKING_LEVELS[thinkingIndices[row.modelIndex]], }); } - return; } }, }; diff --git a/extensions/leader-key/index.ts b/extensions/leader-key/index.ts index 2e545c3..d9fedfd 100644 --- a/extensions/leader-key/index.ts +++ b/extensions/leader-key/index.ts @@ -35,7 +35,11 @@ import { registerBridgeCommands } from "./context-helpers.js"; // Build top-level entries // ───────────────────────────────────────────────────────────────────────────── -function buildEntries(pi: ExtensionAPI, ctx: ExtensionContext): TopLevelEntry[] { +function buildEntries( + pi: ExtensionAPI, + ctx: ExtensionContext, + openFavouriteModels: (ctx: ExtensionContext) => Promise, +): TopLevelEntry[] { const entries: TopLevelEntry[] = []; // ── Session ───────────────────────────────────────────────────────── @@ -44,13 +48,13 @@ function buildEntries(pi: ExtensionAPI, ctx: ExtensionContext): TopLevelEntry[] // ── Labels ────────────────────────────────────────────────────────── entries.push(buildLabelEntries(pi)); - // ── Favourite models ──────────────────────────────────────────────── + // ── Scoped models ─────────────────────────────────────────────────── entries.push({ type: "action", key: "m", - label: "Favourites", - description: "quick-switch favourite models", - action: (ctx) => runFavouriteModels(pi, ctx), + label: "Scoped", + description: "quick-switch Pi scoped models", + action: (ctx) => openFavouriteModels(ctx), }); // ── Permissions mode ──────────────────────────────────────────────── @@ -449,10 +453,24 @@ export default function leaderKeyExtension(pi: ExtensionAPI) { // Register internal commands that bridge shortcut→command context registerBridgeCommands(pi); + let stopFavouriteModelsShortcut: (() => void) | undefined; + let favouriteModelsOpen = false; + + async function openFavouriteModels(ctx: ExtensionContext) { + if (!ctx.hasUI || favouriteModelsOpen) return; + + favouriteModelsOpen = true; + try { + await runFavouriteModels(pi, ctx); + } finally { + favouriteModelsOpen = false; + } + } + async function openLeaderKey(ctx: ExtensionContext) { if (!ctx.hasUI) return; - const entries = buildEntries(pi, ctx); + const entries = buildEntries(pi, ctx, openFavouriteModels); const selected = await ctx.ui.custom( (tui, theme, _kb, done) => { @@ -486,6 +504,22 @@ export default function leaderKeyExtension(pi: ExtensionAPI) { } } + pi.on("session_start", async (_event, ctx) => { + if (!ctx.hasUI) return; + + stopFavouriteModelsShortcut?.(); + stopFavouriteModelsShortcut = ctx.ui.onTerminalInput((data) => { + if (parseKey(data) !== Key.ctrl("m")) return; + void openFavouriteModels(ctx); + return { consume: true }; + }); + }); + + pi.on("session_shutdown", async () => { + stopFavouriteModelsShortcut?.(); + stopFavouriteModelsShortcut = undefined; + }); + // Register as a command pi.registerCommand("lk", { description: "Open Leader Key palette", diff --git a/extensions/leader-key/label-actions.ts b/extensions/leader-key/label-actions.ts index a165816..d6bef51 100644 --- a/extensions/leader-key/label-actions.ts +++ b/extensions/leader-key/label-actions.ts @@ -5,8 +5,10 @@ import type { ExtensionAPI, ExtensionContext, + SessionManager, } from "@mariozechner/pi-coding-agent"; -import type { SessionTreeNode } from "@mariozechner/pi-coding-agent/dist/core/session-manager.js"; + +type SessionTreeNode = ReturnType[number]; import type { TopLevelEntry } from "./types.js"; import { searchableSelect } from "./model-switcher.js"; import { collectLabeledEntries, tryNavigateTree } from "./context-helpers.js"; -- 2.51.2 From 81e1c9294f749df6e81381bffc32c5b0586c12e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:31:03 +0200 Subject: [PATCH 29/66] refactor(pi-telescope): update sessions provider and overlay --- extensions/pi-telescope/providers/sessions.ts | 56 ++++++++++++++++++- extensions/pi-telescope/telescope.ts | 23 ++++---- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/extensions/pi-telescope/providers/sessions.ts b/extensions/pi-telescope/providers/sessions.ts index 9615b59..4a0c529 100644 --- a/extensions/pi-telescope/providers/sessions.ts +++ b/extensions/pi-telescope/providers/sessions.ts @@ -14,6 +14,11 @@ import type { SessionEntry } from "@mariozechner/pi-coding-agent"; import { Markdown, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import type { TelescopeProvider } from "../types.js"; import { copyToClipboard } from "../clipboard.js"; +import { + listIndexedSessions, + searchSessions as searchStoredSessions, + type SessionRecord, +} from "../../session-store/db.js"; const SESSION_BASE = join(process.env.HOME ?? "~", ".pi/agent/sessions"); @@ -108,7 +113,19 @@ function parseSession(filePath: string): SessionInfo | null { } } -function findSessions(): SessionInfo[] { +function recordToInfo(record: SessionRecord): SessionInfo { + const firstMessage = record.search_text.split("\n")[0]?.trim() || "(empty)"; + return { + path: record.path, + cwd: record.cwd, + name: record.name ?? undefined, + firstMessage, + modified: new Date(record.file_mtime || record.last_activity_at || Date.now()), + messageCount: record.user_message_count + record.assistant_message_count, + }; +} + +function findSessionsFromFiles(): SessionInfo[] { if (!existsSync(SESSION_BASE)) return []; const results: SessionInfo[] = []; @@ -130,6 +147,35 @@ function findSessions(): SessionInfo[] { return results; } +function findSessions(): SessionInfo[] { + try { + const indexed = listIndexedSessions({ limit: 5000 }) + .filter((record) => existsSync(record.path)) + .map(recordToInfo); + if (indexed.length > 0) return indexed; + } catch { + // Store may be unavailable during early startup; fall back to direct scan. + } + return findSessionsFromFiles(); +} + +function searchSessions(query: string): SessionInfo[] { + const trimmed = query.trim(); + if (!trimmed) return findSessions(); + + try { + return searchStoredSessions(trimmed, 500) + .filter((result) => existsSync(result.record.path)) + .map((result) => recordToInfo(result.record)); + } catch { + const lower = trimmed.toLowerCase(); + return findSessionsFromFiles().filter((session) => { + const text = `${session.name ?? ""} ${session.firstMessage} ${session.cwd}`.toLowerCase(); + return text.includes(lower); + }); + } +} + // ── Message extraction ─────────────────────────────── const messageCache = new Map(); @@ -301,9 +347,15 @@ export function createSessionsProvider(): TelescopeProvider { return findSessions(); }, + supportsDynamicSearch: true, + + async search(query) { + return searchSessions(query); + }, + getSearchText(item) { const label = item.name ?? item.firstMessage; - return `${label} ${item.cwd}`; + return `${label} ${item.firstMessage} ${item.cwd} ${item.path}`; }, getDisplayText(item, theme) { diff --git a/extensions/pi-telescope/telescope.ts b/extensions/pi-telescope/telescope.ts index 1139898..92d236a 100644 --- a/extensions/pi-telescope/telescope.ts +++ b/extensions/pi-telescope/telescope.ts @@ -200,7 +200,15 @@ export async function openTelescope( } catch {} state.loading = false; } else { - state.filtered = []; + try { + state.allItems = await currentProvider.load(ctx.cwd); + } catch {} + state.filtered = state.allItems.map((item) => ({ + item, + score: 0, + indices: [], + })); + state.loading = false; } state.selectedIndex = 0; state.scrollOffset = 0; @@ -317,12 +325,7 @@ export async function openTelescope( state.loading = true; exitMode(); - if (!currentProvider.supportsDynamicSearch) { - loadItems(); - } else { - state.loading = false; - tui.requestRender(); - } + loadItems(); }; // ── Selection helpers ──────────────────────── @@ -800,11 +803,7 @@ export async function openTelescope( // ── Init ───────────────────────────────────── - if (!currentProvider.supportsDynamicSearch) { - loadItems(); - } else { - state.loading = false; - } + loadItems(); return { render, -- 2.51.2 From e53cf4e74fdf183548af7503a4850a3b6f54b268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:31:03 +0200 Subject: [PATCH 30/66] refactor(tool-pills): drop find and grep pills --- extensions/tool-pills/index.ts | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/extensions/tool-pills/index.ts b/extensions/tool-pills/index.ts index 6099847..f934b2e 100644 --- a/extensions/tool-pills/index.ts +++ b/extensions/tool-pills/index.ts @@ -1,15 +1,13 @@ /** * Tool Pills + Diff Renderer — combined extension. * - * • ls, read, find, grep, bash → colored pill labels + collapsed output + * • ls, read, bash → colored pill labels + collapsed output * • write, edit → Shiki-powered syntax-highlighted diffs (from pi-diff) */ import type { ExtensionAPI, AgentToolResult, Theme } from "@mariozechner/pi-coding-agent"; import { createBashToolDefinition, - createFindToolDefinition, - createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, highlightCode, @@ -60,7 +58,7 @@ function renderTextResult( return new Text(`\n${output}\n${hint}`, 0, 0); } -/** Helper to register a basic tool (ls, read, find, grep) with pill + collapsed output. */ +/** Helper to register a basic tool (ls, read) with pill + collapsed output. */ function wrapBasicTool( pi: ExtensionAPI, orig: any, @@ -100,20 +98,7 @@ export default function (pi: ExtensionAPI) { return t; }); - // find - wrapBasicTool(pi, createFindToolDefinition(cwd), "find", (args, theme) => { - let t = theme.fg("accent", `"${args.pattern}"`); - if (args.path) t += theme.fg("dim", ` in ${args.path}`); - return t; - }); - - // grep - wrapBasicTool(pi, createGrepToolDefinition(cwd), "grep", (args, theme) => { - let t = theme.fg("accent", `"${args.pattern}"`); - if (args.path) t += theme.fg("dim", ` in ${args.path}`); - if (args.glob) t += theme.fg("dim", ` ${args.glob}`); - return t; - }); + // find + grep: handled by pi-fff extension (SIMD-accelerated, own rendering) // bash — special: syntax-highlighted command, tail mode const origBash = createBashToolDefinition(cwd); -- 2.51.2 From a2206706f3f2fdb5f6f26bc90ac99b5e9df2f913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Tue, 30 Jun 2026 06:31:03 +0200 Subject: [PATCH 31/66] chore(deps): upgrade pi to 0.69 and migrate to typebox --- bun.lock | 367 ++++-- extensions/ask-user-question/schema.ts | 2 +- extensions/cmux/tools.ts | 2 +- extensions/handoff/index.ts | 2 +- extensions/pi-sem/index.ts | 2 +- extensions/session-query/session-query.ts | 2 +- package-lock.json | 1309 ++++++++++++--------- package.json | 15 +- 8 files changed, 1070 insertions(+), 631 deletions(-) diff --git a/bun.lock b/bun.lock index 2104aa8..612f42a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,23 @@ "": { "name": "pi-ext", "dependencies": { - "@mariozechner/pi-ai": "^0.62.0", - "@mariozechner/pi-coding-agent": "^0.62.0", - "@mariozechner/pi-tui": "^0.62.0", + "@benvargas/pi-claude-code-use": "^1.0.1", + "@mariozechner/pi-ai": "^0.69.0", + "@mariozechner/pi-coding-agent": "^0.69.0", + "@mariozechner/pi-tui": "^0.69.0", + "@sting8k/pi-vcc": "^0.3.5", + "@tintinweb/pi-tasks": "^0.4.2", + "typebox": "^1.1.32", + }, + "optionalDependencies": { + "@ataraxy-labs/sem": "^0.3.21", }, }, }, "packages": { - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.73.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.90.0", "", { "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-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg=="], + + "@ataraxy-labs/sem": ["@ataraxy-labs/sem@0.3.23", "", { "bin": { "sem": "bin/sem.js" } }, "sha512-TBus1sdEYlyRXUUm9w2djoTqJlGf6sSs8ecmUoSCmuCzBAkxixjq19g6VGH8XPJL8spptKQzacVRVhcGTTd6wQ=="], "@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=="], @@ -24,64 +33,72 @@ "@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-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1002.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/credential-provider-node": "^3.972.16", "@aws-sdk/eventstream-handler-node": "^3.972.9", "@aws-sdk/middleware-eventstream": "^3.972.6", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/middleware-websocket": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/token-providers": "3.1002.0", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/eventstream-serde-config-resolver": "^4.3.10", "@smithy/eventstream-serde-node": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-stream": "^4.5.16", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-xUmzgTvTeQFVxBqla8U4nXpZNXLcZ0xszfZ4yxdTUNyChQQb7JLaH4E8pAbl7ulg0RoJ4ChNWtOqMJC/N3+qcQ=="], + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1035.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.4", "@aws-sdk/credential-provider-node": "^3.972.35", "@aws-sdk/eventstream-handler-node": "^3.972.14", "@aws-sdk/middleware-eventstream": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.34", "@aws-sdk/middleware-websocket": "^3.972.16", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/token-providers": "3.1035.0", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.20", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.16", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/eventstream-serde-config-resolver": "^4.3.14", "@smithy/eventstream-serde-node": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.31", "@smithy/middleware-retry": "^4.5.4", "@smithy/middleware-serde": "^4.2.19", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.0", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.12", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@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.48", "@smithy/util-defaults-mode-node": "^4.2.53", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.3", "@smithy/util-stream": "^4.5.24", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XELIQk+znh53J2Bj0EmOftgcKRLw3tvI/P4WHLgSbSBWPh+wg0SvHu+bgIlzMHARbOC9auA0lsH+Eb9JwF1yjA=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.974.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.18", "@smithy/core": "^3.23.16", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.12", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.3", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-EbVgyzQ83/Lf6oh1O4vYY47tuYw3Aosthh865LNU77KyotKz+uvEBNmsl/bSVS/vG+IU39mCqcOHrnhmhF4lug=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.30", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-dHpeqa29a0cBYq/h59IC2EK3AphLY96nKy4F35kBtiz9GuKDc32UYRTgjZaF8uuJCnqgw9omUZKR+9myyDHC2A=="], - "@aws-sdk/core": ["@aws-sdk/core@3.973.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/xml-builder": "^3.972.9", "@smithy/core": "^3.23.7", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.32", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.0", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.12", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.24", "tslib": "^2.6.2" } }, "sha512-A+ZTT//Mswkf9DFEM6XlngwOtYdD8X4CUcoZ2wdpgI8cCs9mcGeuhgTwbGJvealub/MeONOaUr3FbRPMKmTDjg=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RhHQG1lhkWHL4tK1C/KDjaOeis+9U0tAMnWDiwiSVQZMC7CsST9Xin+sK89XywJ5g/tyABtb7TvFePJ4Te5XSQ=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/credential-provider-env": "^3.972.30", "@aws-sdk/credential-provider-http": "^3.972.32", "@aws-sdk/credential-provider-login": "^3.972.34", "@aws-sdk/credential-provider-process": "^3.972.30", "@aws-sdk/credential-provider-sso": "^3.972.34", "@aws-sdk/credential-provider-web-identity": "^3.972.34", "@aws-sdk/nested-clients": "^3.997.2", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-MoRc7tLnx3JpFkV2R826enEfBUVN8o9Cc7y3hnbMwiWzL/VJhgfxRQzHkEL9vWorMWP7tibltsRcLoid9fsVdw=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/node-http-handler": "^4.4.13", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" } }, "sha512-b/bDL76p51+yQ+0O9ZDH5nw/ioE0sRYkjwjOwFWAWZXo6it2kQZUOXhVpjohx3ldKyUxt/SwAivjUu1Nr/PWlQ=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/nested-clients": "^3.997.2", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-XVSklkRRQ/CQDmv3VVFdZRl5hTFgncFhZrLyi0Ai4LZk5o3jpY5HIfuTK7ad7tixPKa+iQmL9+vg9qNyYZB+nw=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/credential-provider-env": "^3.972.15", "@aws-sdk/credential-provider-http": "^3.972.17", "@aws-sdk/credential-provider-login": "^3.972.15", "@aws-sdk/credential-provider-process": "^3.972.15", "@aws-sdk/credential-provider-sso": "^3.972.15", "@aws-sdk/credential-provider-web-identity": "^3.972.15", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-qWnM+wB8MmU2kKY7f4KowKjOjkwRosaFxrtseEEIefwoXn1SjN+CbHzXBVdTAQxxkbBiqhPgJ/WHiPtES4grRQ=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.35", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.30", "@aws-sdk/credential-provider-http": "^3.972.32", "@aws-sdk/credential-provider-ini": "^3.972.34", "@aws-sdk/credential-provider-process": "^3.972.30", "@aws-sdk/credential-provider-sso": "^3.972.34", "@aws-sdk/credential-provider-web-identity": "^3.972.34", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-nVrY7AdGfzYgAa/jd9m06p3ES7QQDaB7zN9c+vXnVXxBRkAs9MjRDPB5AKogWuC6phddltfvHGFqLDJmyU9u/A=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x92FJy34/95wgu+qOGD8SHcgh1hZ9Qx2uFtQEGn4m9Ljou8ICIv3Ybq5yxdB7A60S8ZGCQB0mIopmjJwiLbh5g=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.30", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-McJPomNTSEo+C6UA3Zq6pFrcyTUaVsoPPBOvbOHAoIFPc8Z2CMLndqFJOnB+9bVFiBTWQLutlVGmrocBbvv4MQ=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.16", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.15", "@aws-sdk/credential-provider-http": "^3.972.17", "@aws-sdk/credential-provider-ini": "^3.972.15", "@aws-sdk/credential-provider-process": "^3.972.15", "@aws-sdk/credential-provider-sso": "^3.972.15", "@aws-sdk/credential-provider-web-identity": "^3.972.15", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-7mlt14Ee4rPFAFUVgpWE7+0CBhetJJyzVFqfIsMp7sgyOSm9Y/+qHZOWAuK5I4JNc+Y5PltvJ9kssTzRo92iXQ=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/nested-clients": "^3.997.2", "@aws-sdk/token-providers": "3.1035.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-WngYb2K+/yhkDOmDfAOjoCa9Ja3he0DZiAraboKwgWoVRkajDIcDYBCVbUTxtTUldvQoe7VvHLTrBNxvftN1aQ=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-PrH3iTeD18y/8uJvQD2s/T87BTGhsdS/1KZU7ReWHXsplBwvCqi7AbnnNbML1pFlQwRWCE2RdSZFWDVId3CvkA=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/nested-clients": "^3.997.2", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-5KLUH+XmSNRj6amJiJSrPsCxU5l/PYDfxyqPa1MxWhHoQC3sxvGPrSib3IE+HQlfRA4e2kO0bnJy7HJdjvpuuA=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/token-providers": "3.1002.0", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-M/+LBHTPKZxxXckM6m4dnJeR+jlm9NynH9b2YDswN4Zj2St05SK/crdL3Wy3WfJTZootnnhm3oTh87Usl7PS7w=="], + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/eventstream-codec": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-QTH6k93v+UOfFam/ado8zc71tH+enTVyuvLy9uEWXX1x894dN5ovtf/MdBDgFwq3g6c9mbtgVJ4B+yBqDtXvdA=="], + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ=="], - "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/eventstream-codec": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA=="], + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg=="], - "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw=="], + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ=="], - "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w=="], + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.33", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.16", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.12", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.24", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-n8Eh/+kq3u/EodLr8n6sQupu03QGjf122RHXCTGLaHSkavz/2beSKpRlq2oDgfmJZNkAkWF113xbyaUmyOd+YA=="], - "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@smithy/core": "^3.23.16", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-retry": "^4.3.3", "tslib": "^2.6.2" } }, "sha512-jrmJHyYlTQocR7H4VhvSFhaoedMb2rmlOTvFWD6tNBQ/EVQhTsrNfQUYFuPiOc2wUGxbm5LgCHtnvVmCPgODHw=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@smithy/core": "^3.23.7", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HHArkgWzomuwufXwheQqkddu763PWCpoNTq1dGjqXzJT/lojX3VlOqjNSR2Xvb6/T9ISfwYcMOcbFgUp4EWxXA=="], + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-format-url": "^3.972.10", "@smithy/eventstream-codec": "^4.2.14", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg=="], - "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-format-url": "^3.972.6", "@smithy/eventstream-codec": "^4.2.10", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-cWf+8iUUnitgFuUu/ryK2uVfx7f5ezdhGwsjLLEEC1Nk716Ld2Hw4LA8iipyVcQI3EarvK6ExY2dSBET/0PYng=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.2", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.4", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.34", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/signature-v4-multi-region": "^3.996.21", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.20", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.16", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.31", "@smithy/middleware-retry": "^4.5.4", "@smithy/middleware-serde": "^4.2.19", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.0", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.12", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@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.48", "@smithy/util-defaults-mode-node": "^4.2.53", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.3", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-uGGQO08YetrqfInOKG5atRMrCDRQWRuZ9gGfKY6svPmuE4K7ac+XcbCkpWpjcA7yCYsBaKB/Nly4XKgPXUO1PA=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.5", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw=="], + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/config-resolver": "^4.4.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A=="], - "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/config-resolver": "^4.4.9", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.21", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.33", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-3EpT+C0QdmTMB5aVeJ5odWSLt9vg2oGzUXl1xvUazKGlkr9OBYnegNWqhhjGgZdv8RmSi5eS8nqqB+euNP2aqA=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1002.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x972uKOydFn4Rb0PZJzLdNW59rH0KWC78Q2JbQzZpGlGt0DxjYdDRwBG6F42B1MyaEwHGqO/tkGc4r3/PRFfMw=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1035.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.4", "@aws-sdk/nested-clients": "^3.997.2", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-E6IO3Cn+OzBe6Sb5pnubd5Y8qSUMAsVKkD5QSwFfIx5fV1g5SkYwUDRDyPlm90RuIVcCo28wpMJU6W8wXH46Aw=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], + "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" } }, "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ=="], + "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="], - "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw=="], + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" } }, "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g=="], + + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ=="], "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="], - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA=="], + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.2", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/types": "^3.973.4", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-lpaIuekdkpw7VRiik0IZmd6TyvEUcuLgKZ5fKRGpCA3I4PjrD/XH15sSwW+OptxQjNU4DEzSxag70spC9SluvA=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.20", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.34", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-owEqyKr0z5hWwk+uHwudwNhyFMZ9f9eSWr/k/XD6yeDCI7hHyc56s4UOY1iBQmoramTbdAY4UCuLLEuKmjVXrg=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.9", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.18", "", { "dependencies": { "@smithy/types": "^4.14.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@benvargas/pi-claude-code-use": ["@benvargas/pi-claude-code-use@1.0.1", "", { "dependencies": { "@mariozechner/jiti": "^2.6.5", "@mariozechner/pi-ai": "*", "@sinclair/typebox": "*" }, "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.57.0" } }, "sha512-R7VgxMew5rWz3Jj1vQKra86MpxX4mzb4ohgRpt7ZFtzIejV6CDNf+fxv68IQW2Zz/2bghmdh0XL/RSzkcjPkDQ=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], "@google/genai": ["@google/genai@1.44.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A=="], @@ -112,15 +129,15 @@ "@mariozechner/jiti": ["@mariozechner/jiti@2.6.5", "", { "dependencies": { "std-env": "^3.10.0", "yoctocolors": "^2.1.2" }, "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw=="], - "@mariozechner/pi-agent-core": ["@mariozechner/pi-agent-core@0.62.0", "", { "dependencies": { "@mariozechner/pi-ai": "^0.62.0" } }, "sha512-SBjqgDrgKOaC+IGzFGB3jXQErv9H1QMYnWFvUg6ra6dG0ZgWFBUZb6unidngWLsmaxSDWes6KeKiVFMsr2VSEQ=="], + "@mariozechner/pi-agent-core": ["@mariozechner/pi-agent-core@0.69.0", "", { "dependencies": { "@mariozechner/pi-ai": "^0.69.0", "typebox": "^1.1.24" } }, "sha512-dYZjee7QEIwVVbDGCGriiLjDT34IdMlXMjMp4drVKcLEuVc+BztyODwu3w4ZORVvwaySGGlF9UALjJKUVoXiug=="], - "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.62.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.983.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "1.14.1", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "6.26.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-mJgryZ5RgBQG++tiETMtCQQJoH2MAhKetCfqI98NMvGydu7L9x2qC2JekQlRaAgIlTgv4MRH1UXHMEs4UweE/Q=="], + "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.69.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "^2.2.0", "chalk": "^5.6.2", "openai": "6.26.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", "typebox": "^1.1.24", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-bl838sr57zx/apkiTeNPFQgbBkGXN4HHhm2oghzSRohJIBrR+H001bTeco7Osuke+EZTxO3V5Aev2qlZdUa2xw=="], - "@mariozechner/pi-coding-agent": ["@mariozechner/pi-coding-agent@0.62.0", "", { "dependencies": { "@mariozechner/jiti": "^2.6.2", "@mariozechner/pi-agent-core": "^0.62.0", "@mariozechner/pi-ai": "^0.62.0", "@mariozechner/pi-tui": "^0.62.0", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "marked": "^15.0.12", "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", "strip-ansi": "^7.1.0", "undici": "^7.19.1", "yaml": "^2.8.2" }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.2" }, "bin": { "pi": "dist/cli.js" } }, "sha512-f1NnExqsHuA6w8UVlBtPsvTBhdkMc0h1JD9SzGCdWTLou5GHJr2JIP6DlwV9IKWAnM+sAelaoFez+14wLP2zOQ=="], + "@mariozechner/pi-coding-agent": ["@mariozechner/pi-coding-agent@0.69.0", "", { "dependencies": { "@mariozechner/jiti": "^2.6.2", "@mariozechner/pi-agent-core": "^0.69.0", "@mariozechner/pi-ai": "^0.69.0", "@mariozechner/pi-tui": "^0.69.0", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "marked": "^15.0.12", "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", "strip-ansi": "^7.1.0", "typebox": "^1.1.24", "undici": "^7.19.1", "uuid": "^11.1.0", "yaml": "^2.8.2" }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.2" }, "bin": { "pi": "dist/cli.js" } }, "sha512-/EFn43RO2TtMgZI4fMXNepIvbWm2GqhhaLIHBkrO7tRbRCzgvDYkw9sMLKhdf1Ihom2CjC3T/fzQRIPasRSIrQ=="], - "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.62.0", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, "optionalDependencies": { "koffi": "^2.9.0" } }, "sha512-/At11PPe8l319MnUoK4wN5L/uVCU6bDdiIUzH8Ez0stOkjSF6isRXScZ+RMM+6iCKsD4muBTX8Cmcif+3/UWHA=="], + "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.69.0", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, "optionalDependencies": { "koffi": "^2.9.0" } }, "sha512-/82SOMzCA1srivwfcSO8r9XLdAgJNcgpWzNZY33IqqvGev8Q4iWY/hA1wAkLB0l7PKJJ4Dh57xS4IGHbyVM7lA=="], - "@mistralai/mistralai": ["@mistralai/mistralai@1.14.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ=="], + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -150,63 +167,63 @@ "@smithy/abort-controller": ["@smithy/abort-controller@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ=="], - "@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/config-resolver": ["@smithy/config-resolver@4.4.17", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ=="], - "@smithy/core": ["@smithy/core@3.23.8", "", { "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-f7uPeBi7ehmLT4YF2u9j3qx6lSnurG1DLXOsTtJrIRNDF7VXio4BGHQ+SQteN/BrUVudbkuL4v7oOsRCzq4BqA=="], + "@smithy/core": ["@smithy/core@3.23.16", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.24", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w=="], - "@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/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg=="], - "@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-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], - "@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-browser": ["@smithy/eventstream-serde-browser@4.2.14", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ=="], - "@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-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA=="], - "@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-node": ["@smithy/eventstream-serde-node@4.2.14", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw=="], - "@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/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.14", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg=="], - "@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/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.17", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw=="], - "@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-node": ["@smithy/hash-node@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g=="], - "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g=="], + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw=="], "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], - "@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-content-length": ["@smithy/middleware-content-length@4.2.14", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw=="], - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.22", "", { "dependencies": { "@smithy/core": "^3.23.8", "@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-sc81w1o4Jy+/MAQlY3sQ8C7CmSpcvIi3TAzXblUv2hjG11BBSJi/Cw8vDx5BxMxapuH2I+Gc+45vWsgU07WZRQ=="], + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.31", "", { "dependencies": { "@smithy/core": "^3.23.16", "@smithy/middleware-serde": "^4.2.19", "@smithy/node-config-provider": "^4.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-KJPdCIN2kOE2aGmqZd7eUTr4WQwOGgtLWgUkswGJggs7rBcQYQjcZMEDa3C0DwbOiXS9L8/wDoQHkfxBYLfiLw=="], - "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.39", "", { "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.2", "@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-MCVCxaCzuZgiHtHGV2Ke44nh6t4+8/tO+rTYOzrr2+G4nMLU/qbzNCWKBX54lyEaVcGQrfOJiG2f8imtiw+nIQ=="], + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.5.4", "", { "dependencies": { "@smithy/core": "^3.23.16", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/service-error-classification": "^4.3.0", "@smithy/smithy-client": "^4.12.12", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.3", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-/z7nIFK+ZRW3Ie/l3NEVGdy34LvmEOzBrtBAvgWZ/4PrKX0xP3kWm8pkfcwUk523SqxZhdbQP9JSXgjF77Uhpw=="], - "@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-serde": ["@smithy/middleware-serde@4.2.19", "", { "dependencies": { "@smithy/core": "^3.23.16", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Q6y+W9h3iYVMCKWDoVge+OC1LKFqbEKaq8SIWG2X2bWJRpd/6dDLyICcNLT6PbjH3Rr6bmg/SeDB25XFOFfeEw=="], - "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg=="], + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA=="], - "@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-config-provider": ["@smithy/node-config-provider@4.3.14", "", { "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg=="], - "@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/node-http-handler": ["@smithy/node-http-handler@4.6.0", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-P734cAoTFtuGfWa/R3jgBnGlURt2w9bYEBwQNMKf58sRM9RShirB2mKwLsVP+jlG/wxpCu8abv8NxdUts8tdLA=="], - "@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/property-provider": ["@smithy/property-provider@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ=="], - "@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/protocol-http": ["@smithy/protocol-http@5.3.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ=="], - "@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-builder": ["@smithy/querystring-builder@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A=="], - "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ=="], + "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw=="], - "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw=="], + "@smithy/service-error-classification": ["@smithy/service-error-classification@4.3.0", "", { "dependencies": { "@smithy/types": "^4.14.1" } }, "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A=="], - "@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/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.9", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ=="], - "@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/signature-v4": ["@smithy/signature-v4@5.3.14", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA=="], - "@smithy/smithy-client": ["@smithy/smithy-client@4.12.2", "", { "dependencies": { "@smithy/core": "^3.23.8", "@smithy/middleware-endpoint": "^4.4.22", "@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-HezY3UuG0k4T+4xhFKctLXCA5N2oN+Rtv+mmL8Gt7YmsUY2yhmcLyW75qrSzldfj75IsCW/4UhY3s20KcFnZqA=="], + "@smithy/smithy-client": ["@smithy/smithy-client@4.12.12", "", { "dependencies": { "@smithy/core": "^3.23.16", "@smithy/middleware-endpoint": "^4.4.31", "@smithy/middleware-stack": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.24", "tslib": "^2.6.2" } }, "sha512-daO7SJn4eM6ArbmrEs+/BTbH7af8AEbSL3OMQdcRvvn8tuUcR5rU2n6DgxIV53aXMS42uwK8NgKKCh5XgqYOPQ=="], - "@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + "@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], - "@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/url-parser": ["@smithy/url-parser@4.2.14", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ=="], "@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=="], @@ -218,19 +235,19 @@ "@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.38", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-c8P1mFLNxcsdAMabB8/VUQUbWzFmgujWi4bAXSggcqLYPc8V4U5abqFqOyn+dK4YT+q8UyCVkTO8807t4t2syA=="], + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.48", "", { "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/smithy-client": "^4.12.12", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-hxVRVPYaRDWa6YQdse1aWX1qrksmLsvNyGBKdc32q4jFzSjxYVNWfstknAfR228TnzS4tzgswXRuYIbhXBuXFQ=="], - "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.41", "", { "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.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/UG+9MT3UZAR0fLzOtMJMfWGcjjHvgggq924x/CRy8vRbL+yFf3Z6vETlvq8vDH92+31P/1gSOFoo7303wN8WQ=="], + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.53", "", { "dependencies": { "@smithy/config-resolver": "^4.4.17", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/smithy-client": "^4.12.12", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-ybgCk+9JdBq8pYC8Y6U5fjyS8e4sboyAShetxPNL0rRBtaVl56GSFAxsolVBIea1tXR4LPIzL8i6xqmcf0+DCQ=="], - "@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-endpoints": ["@smithy/util-endpoints@3.4.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg=="], "@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-middleware": ["@smithy/util-middleware@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw=="], - "@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-retry": ["@smithy/util-retry@4.3.3", "", { "dependencies": { "@smithy/service-error-classification": "^4.3.0", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-idjUvd4M9Jj6rXkhqw4H4reHoweuK4ZxYWyOrEp4N2rOF5VtaOlQGLDQJva/8WanNXk9ScQtsAb7o5UHGvFm4A=="], - "@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-stream": ["@smithy/util-stream@4.5.24", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.0", "@smithy/types": "^4.14.1", "@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-na5vv2mBSDzXewLEEoWGI7LQQkfpmFEomBsmOpzLFjqGctm0iMwXY5lAwesY9pIaErkccW0qzEOUcYP+WKneXg=="], "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], @@ -238,6 +255,10 @@ "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + "@sting8k/pi-vcc": ["@sting8k/pi-vcc@0.3.11", "", { "peerDependencies": { "@mariozechner/pi-coding-agent": "*", "@sinclair/typebox": "*" } }, "sha512-SkBOr43L9Y/080qXtWWIYpgHfKpQlyKjy7V0huYGW2Xnp/geZyRRDAKIF389gBGtg9Ef0opzWcyRgu3KpUUmYg=="], + + "@tintinweb/pi-tasks": ["@tintinweb/pi-tasks@0.4.2", "", { "dependencies": { "@mariozechner/pi-coding-agent": "^0.62.0", "@mariozechner/pi-tui": "^0.62.0", "@sinclair/typebox": "latest" } }, "sha512-H8TwN13HHYOqwBHwLWPfALOHehWS2UxmCTcKUmHsZnoAd087hElnuz7GUqwBR44lzxp3TCSagUikOhPfrRLGlQ=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -328,9 +349,9 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-xml-builder": ["fast-xml-builder@1.0.0", "", {}, "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ=="], + "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="], - "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=="], + "fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], @@ -440,6 +461,8 @@ "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "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=="], @@ -506,12 +529,16 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typebox": ["typebox@1.1.32", "", {}, "sha512-cbGoj7BCxGcFDJ/RR7wbyMe9IkO2SeNhwLdZWQ+xRtun9+ze9iM1pBND4SoFAxgonuJYrCIWnEQ7sE4bMVDYHA=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -540,14 +567,30 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], + + "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], + "@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.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], + + "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], + "@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=="], "@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=="], "@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=="], + "@smithy/abort-controller/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent": ["@mariozechner/pi-coding-agent@0.62.0", "", { "dependencies": { "@mariozechner/jiti": "^2.6.2", "@mariozechner/pi-agent-core": "^0.62.0", "@mariozechner/pi-ai": "^0.62.0", "@mariozechner/pi-tui": "^0.62.0", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "marked": "^15.0.12", "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", "strip-ansi": "^7.1.0", "undici": "^7.19.1", "yaml": "^2.8.2" }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.2" }, "bin": { "pi": "dist/cli.js" } }, "sha512-f1NnExqsHuA6w8UVlBtPsvTBhdkMc0h1JD9SzGCdWTLou5GHJr2JIP6DlwV9IKWAnM+sAelaoFez+14wLP2zOQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.62.0", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, "optionalDependencies": { "koffi": "^2.9.0" } }, "sha512-/At11PPe8l319MnUoK4wN5L/uVCU6bDdiIUzH8Ez0stOkjSF6isRXScZ+RMM+6iCKsD4muBTX8Cmcif+3/UWHA=="], + + "@tintinweb/pi-tasks/@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -576,14 +619,26 @@ "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + + "@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + "@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/sha256-js/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + + "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + "@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=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-agent-core": ["@mariozechner/pi-agent-core@0.62.0", "", { "dependencies": { "@mariozechner/pi-ai": "^0.62.0" } }, "sha512-SBjqgDrgKOaC+IGzFGB3jXQErv9H1QMYnWFvUg6ra6dG0ZgWFBUZb6unidngWLsmaxSDWes6KeKiVFMsr2VSEQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.62.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.983.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "1.14.1", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "6.26.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-mJgryZ5RgBQG++tiETMtCQQJoH2MAhKetCfqI98NMvGydu7L9x2qC2JekQlRaAgIlTgv4MRH1UXHMEs4UweE/Q=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "rimraf/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -602,10 +657,180 @@ "@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=="], + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.73.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1002.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/credential-provider-node": "^3.972.16", "@aws-sdk/eventstream-handler-node": "^3.972.9", "@aws-sdk/middleware-eventstream": "^3.972.6", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/middleware-websocket": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/token-providers": "3.1002.0", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/eventstream-serde-config-resolver": "^4.3.10", "@smithy/eventstream-serde-node": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-stream": "^4.5.16", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-xUmzgTvTeQFVxBqla8U4nXpZNXLcZ0xszfZ4yxdTUNyChQQb7JLaH4E8pAbl7ulg0RoJ4ChNWtOqMJC/N3+qcQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@mistralai/mistralai": ["@mistralai/mistralai@1.14.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/core": ["@aws-sdk/core@3.973.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/xml-builder": "^3.972.9", "@smithy/core": "^3.23.7", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.16", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.15", "@aws-sdk/credential-provider-http": "^3.972.17", "@aws-sdk/credential-provider-ini": "^3.972.15", "@aws-sdk/credential-provider-process": "^3.972.15", "@aws-sdk/credential-provider-sso": "^3.972.15", "@aws-sdk/credential-provider-web-identity": "^3.972.15", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-7mlt14Ee4rPFAFUVgpWE7+0CBhetJJyzVFqfIsMp7sgyOSm9Y/+qHZOWAuK5I4JNc+Y5PltvJ9kssTzRo92iXQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/eventstream-codec": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@smithy/core": "^3.23.7", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HHArkgWzomuwufXwheQqkddu763PWCpoNTq1dGjqXzJT/lojX3VlOqjNSR2Xvb6/T9ISfwYcMOcbFgUp4EWxXA=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-format-url": "^3.972.6", "@smithy/eventstream-codec": "^4.2.10", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-cWf+8iUUnitgFuUu/ryK2uVfx7f5ezdhGwsjLLEEC1Nk716Ld2Hw4LA8iipyVcQI3EarvK6ExY2dSBET/0PYng=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/config-resolver": "^4.4.9", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1002.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x972uKOydFn4Rb0PZJzLdNW59rH0KWC78Q2JbQzZpGlGt0DxjYdDRwBG6F42B1MyaEwHGqO/tkGc4r3/PRFfMw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" } }, "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.2", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/types": "^3.973.4", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-lpaIuekdkpw7VRiik0IZmd6TyvEUcuLgKZ5fKRGpCA3I4PjrD/XH15sSwW+OptxQjNU4DEzSxag70spC9SluvA=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/core": ["@smithy/core@3.23.8", "", { "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-f7uPeBi7ehmLT4YF2u9j3qx6lSnurG1DLXOsTtJrIRNDF7VXio4BGHQ+SQteN/BrUVudbkuL4v7oOsRCzq4BqA=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.22", "", { "dependencies": { "@smithy/core": "^3.23.8", "@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-sc81w1o4Jy+/MAQlY3sQ8C7CmSpcvIi3TAzXblUv2hjG11BBSJi/Cw8vDx5BxMxapuH2I+Gc+45vWsgU07WZRQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.39", "", { "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.2", "@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-MCVCxaCzuZgiHtHGV2Ke44nh6t4+8/tO+rTYOzrr2+G4nMLU/qbzNCWKBX54lyEaVcGQrfOJiG2f8imtiw+nIQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/smithy-client": ["@smithy/smithy-client@4.12.2", "", { "dependencies": { "@smithy/core": "^3.23.8", "@smithy/middleware-endpoint": "^4.4.22", "@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-HezY3UuG0k4T+4xhFKctLXCA5N2oN+Rtv+mmL8Gt7YmsUY2yhmcLyW75qrSzldfj75IsCW/4UhY3s20KcFnZqA=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.38", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-c8P1mFLNxcsdAMabB8/VUQUbWzFmgujWi4bAXSggcqLYPc8V4U5abqFqOyn+dK4YT+q8UyCVkTO8807t4t2syA=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.41", "", { "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.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/UG+9MT3UZAR0fLzOtMJMfWGcjjHvgggq924x/CRy8vRbL+yFf3Z6vETlvq8vDH92+31P/1gSOFoo7303wN8WQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/util-middleware": ["@smithy/util-middleware@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.9", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/core/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/core/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RhHQG1lhkWHL4tK1C/KDjaOeis+9U0tAMnWDiwiSVQZMC7CsST9Xin+sK89XywJ5g/tyABtb7TvFePJ4Te5XSQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/node-http-handler": "^4.4.13", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" } }, "sha512-b/bDL76p51+yQ+0O9ZDH5nw/ioE0sRYkjwjOwFWAWZXo6it2kQZUOXhVpjohx3ldKyUxt/SwAivjUu1Nr/PWlQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/credential-provider-env": "^3.972.15", "@aws-sdk/credential-provider-http": "^3.972.17", "@aws-sdk/credential-provider-login": "^3.972.15", "@aws-sdk/credential-provider-process": "^3.972.15", "@aws-sdk/credential-provider-sso": "^3.972.15", "@aws-sdk/credential-provider-web-identity": "^3.972.15", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-qWnM+wB8MmU2kKY7f4KowKjOjkwRosaFxrtseEEIefwoXn1SjN+CbHzXBVdTAQxxkbBiqhPgJ/WHiPtES4grRQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-PrH3iTeD18y/8uJvQD2s/T87BTGhsdS/1KZU7ReWHXsplBwvCqi7AbnnNbML1pFlQwRWCE2RdSZFWDVId3CvkA=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/token-providers": "3.1002.0", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-M/+LBHTPKZxxXckM6m4dnJeR+jlm9NynH9b2YDswN4Zj2St05SK/crdL3Wy3WfJTZootnnhm3oTh87Usl7PS7w=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-QTH6k93v+UOfFam/ado8zc71tH+enTVyuvLy9uEWXX1x894dN5ovtf/MdBDgFwq3g6c9mbtgVJ4B+yBqDtXvdA=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/eventstream-handler-node/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-websocket/@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-websocket/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-websocket/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.5", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/eventstream-serde-browser/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/eventstream-serde-node/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/fetch-http-handler/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/middleware-endpoint/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/middleware-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/node-config-provider/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/node-config-provider/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/node-http-handler/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/url-parser/@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/util-defaults-mode-browser/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/util-defaults-mode-node/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/util-defaults-mode-node/@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/util-retry/@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x92FJy34/95wgu+qOGD8SHcgh1hZ9Qx2uFtQEGn4m9Ljou8ICIv3Ybq5yxdB7A60S8ZGCQB0mIopmjJwiLbh5g=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.5", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.5", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.5", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/middleware-websocket/@aws-sdk/util-format-url/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/eventstream-serde-browser/@smithy/eventstream-serde-universal/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/eventstream-serde-node/@smithy/eventstream-serde-universal/@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=="], + + "@tintinweb/pi-tasks/@mariozechner/pi-coding-agent/@mariozechner/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/core/@aws-sdk/xml-builder/fast-xml-parser/fast-xml-builder": ["fast-xml-builder@1.0.0", "", {}, "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ=="], } } diff --git a/extensions/ask-user-question/schema.ts b/extensions/ask-user-question/schema.ts index dc74e9e..dc7711e 100644 --- a/extensions/ask-user-question/schema.ts +++ b/extensions/ask-user-question/schema.ts @@ -1,4 +1,4 @@ -import { type Static, Type } from "@sinclair/typebox"; +import { type Static, Type } from "typebox"; // ── Input (what the LLM sends) ──────────────────────────────────────────────── diff --git a/extensions/cmux/tools.ts b/extensions/cmux/tools.ts index e623471..cd4a123 100644 --- a/extensions/cmux/tools.ts +++ b/extensions/cmux/tools.ts @@ -9,7 +9,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { truncateTail } from "@mariozechner/pi-coding-agent"; import { StringEnum } from "@mariozechner/pi-ai"; -import { Type } from "@sinclair/typebox"; +import { Type } from "typebox"; import type { CmuxClient } from "./cmux-client.js"; /** ToolResult shape expected by pi's agent core. */ diff --git a/extensions/handoff/index.ts b/extensions/handoff/index.ts index efc583b..6f9fb64 100644 --- a/extensions/handoff/index.ts +++ b/extensions/handoff/index.ts @@ -16,7 +16,7 @@ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent"; import { convertToLlm } from "@mariozechner/pi-coding-agent"; import { matchesKey, Key } from "@mariozechner/pi-tui"; -import { Type } from "@sinclair/typebox"; +import { Type } from "typebox"; import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { join, dirname, resolve } from "node:path"; diff --git a/extensions/pi-sem/index.ts b/extensions/pi-sem/index.ts index 6ea7a5b..87ef040 100644 --- a/extensions/pi-sem/index.ts +++ b/extensions/pi-sem/index.ts @@ -6,7 +6,7 @@ import { truncateHead, } from "@mariozechner/pi-coding-agent"; import { StringEnum } from "@mariozechner/pi-ai"; -import { Type } from "@sinclair/typebox"; +import { Type } from "typebox"; import { mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/extensions/session-query/session-query.ts b/extensions/session-query/session-query.ts index 2e8c4b5..5dd8218 100644 --- a/extensions/session-query/session-query.ts +++ b/extensions/session-query/session-query.ts @@ -27,7 +27,7 @@ import { type SessionEntry, } from "@mariozechner/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import { Type } from "@sinclair/typebox"; +import { Type } from "typebox"; import { normalize } from "@sting8k/pi-vcc/src/core/normalize"; import { filterNoise } from "@sting8k/pi-vcc/src/core/filter-noise"; import { buildSections } from "@sting8k/pi-vcc/src/core/build-sections"; diff --git a/package-lock.json b/package-lock.json index 120b3e0..bb384ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,20 +10,21 @@ "license": "MIT", "dependencies": { "@benvargas/pi-claude-code-use": "^1.0.1", - "@mariozechner/pi-ai": "^0.62.0", - "@mariozechner/pi-coding-agent": "^0.62.0", - "@mariozechner/pi-tui": "^0.62.0", + "@mariozechner/pi-ai": "^0.69.0", + "@mariozechner/pi-coding-agent": "^0.69.0", + "@mariozechner/pi-tui": "^0.69.0", "@sting8k/pi-vcc": "^0.3.5", - "@tintinweb/pi-tasks": "^0.4.2" + "@tintinweb/pi-tasks": "^0.4.2", + "typebox": "^1.1.32" }, "optionalDependencies": { "@ataraxy-labs/sem": "^0.3.21" } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.73.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", - "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "version": "0.90.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz", + "integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -194,57 +195,57 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1002.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1002.0.tgz", - "integrity": "sha512-xUmzgTvTeQFVxBqla8U4nXpZNXLcZ0xszfZ4yxdTUNyChQQb7JLaH4E8pAbl7ulg0RoJ4ChNWtOqMJC/N3+qcQ==", + "version": "3.1035.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1035.0.tgz", + "integrity": "sha512-XELIQk+znh53J2Bj0EmOftgcKRLw3tvI/P4WHLgSbSBWPh+wg0SvHu+bgIlzMHARbOC9auA0lsH+Eb9JwF1yjA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/credential-provider-node": "^3.972.16", - "@aws-sdk/eventstream-handler-node": "^3.972.9", - "@aws-sdk/middleware-eventstream": "^3.972.6", - "@aws-sdk/middleware-host-header": "^3.972.6", - "@aws-sdk/middleware-logger": "^3.972.6", - "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-user-agent": "^3.972.17", - "@aws-sdk/middleware-websocket": "^3.972.11", - "@aws-sdk/region-config-resolver": "^3.972.6", - "@aws-sdk/token-providers": "3.1002.0", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.2", - "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.7", - "@smithy/eventstream-serde-browser": "^4.2.10", - "@smithy/eventstream-serde-config-resolver": "^4.3.10", - "@smithy/eventstream-serde-node": "^4.2.10", - "@smithy/fetch-http-handler": "^5.3.12", - "@smithy/hash-node": "^4.2.10", - "@smithy/invalid-dependency": "^4.2.10", - "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.21", - "@smithy/middleware-retry": "^4.4.38", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.13", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.1", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.37", - "@smithy/util-defaults-mode-node": "^4.2.40", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/util-stream": "^4.5.16", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/credential-provider-node": "^3.972.35", + "@aws-sdk/eventstream-handler-node": "^3.972.14", + "@aws-sdk/middleware-eventstream": "^3.972.10", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.34", + "@aws-sdk/middleware-websocket": "^3.972.16", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/token-providers": "3.1035.0", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.20", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.16", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.31", + "@smithy/middleware-retry": "^4.5.4", + "@smithy/middleware-serde": "^4.2.19", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@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.48", + "@smithy/util-defaults-mode-node": "^4.2.53", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.3", + "@smithy/util-stream": "^4.5.24", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -252,23 +253,24 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.17.tgz", - "integrity": "sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w==", + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.4.tgz", + "integrity": "sha512-EbVgyzQ83/Lf6oh1O4vYY47tuYw3Aosthh865LNU77KyotKz+uvEBNmsl/bSVS/vG+IU39mCqcOHrnhmhF4lug==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/xml-builder": "^3.972.9", - "@smithy/core": "^3.23.7", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/signature-v4": "^5.3.10", - "@smithy/smithy-client": "^4.12.1", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.18", + "@smithy/core": "^3.23.16", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.3", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -276,15 +278,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.15.tgz", - "integrity": "sha512-RhHQG1lhkWHL4tK1C/KDjaOeis+9U0tAMnWDiwiSVQZMC7CsST9Xin+sK89XywJ5g/tyABtb7TvFePJ4Te5XSQ==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.30.tgz", + "integrity": "sha512-dHpeqa29a0cBYq/h59IC2EK3AphLY96nKy4F35kBtiz9GuKDc32UYRTgjZaF8uuJCnqgw9omUZKR+9myyDHC2A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -292,20 +294,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.17.tgz", - "integrity": "sha512-b/bDL76p51+yQ+0O9ZDH5nw/ioE0sRYkjwjOwFWAWZXo6it2kQZUOXhVpjohx3ldKyUxt/SwAivjUu1Nr/PWlQ==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.32.tgz", + "integrity": "sha512-A+ZTT//Mswkf9DFEM6XlngwOtYdD8X4CUcoZ2wdpgI8cCs9mcGeuhgTwbGJvealub/MeONOaUr3FbRPMKmTDjg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/types": "^3.973.4", - "@smithy/fetch-http-handler": "^5.3.12", - "@smithy/node-http-handler": "^4.4.13", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.1", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.16", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.24", "tslib": "^2.6.2" }, "engines": { @@ -313,24 +315,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.15.tgz", - "integrity": "sha512-qWnM+wB8MmU2kKY7f4KowKjOjkwRosaFxrtseEEIefwoXn1SjN+CbHzXBVdTAQxxkbBiqhPgJ/WHiPtES4grRQ==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.34.tgz", + "integrity": "sha512-MoRc7tLnx3JpFkV2R826enEfBUVN8o9Cc7y3hnbMwiWzL/VJhgfxRQzHkEL9vWorMWP7tibltsRcLoid9fsVdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/credential-provider-env": "^3.972.15", - "@aws-sdk/credential-provider-http": "^3.972.17", - "@aws-sdk/credential-provider-login": "^3.972.15", - "@aws-sdk/credential-provider-process": "^3.972.15", - "@aws-sdk/credential-provider-sso": "^3.972.15", - "@aws-sdk/credential-provider-web-identity": "^3.972.15", - "@aws-sdk/nested-clients": "^3.996.5", - "@aws-sdk/types": "^3.973.4", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/credential-provider-env": "^3.972.30", + "@aws-sdk/credential-provider-http": "^3.972.32", + "@aws-sdk/credential-provider-login": "^3.972.34", + "@aws-sdk/credential-provider-process": "^3.972.30", + "@aws-sdk/credential-provider-sso": "^3.972.34", + "@aws-sdk/credential-provider-web-identity": "^3.972.34", + "@aws-sdk/nested-clients": "^3.997.2", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -338,18 +340,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.15.tgz", - "integrity": "sha512-x92FJy34/95wgu+qOGD8SHcgh1hZ9Qx2uFtQEGn4m9Ljou8ICIv3Ybq5yxdB7A60S8ZGCQB0mIopmjJwiLbh5g==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.34.tgz", + "integrity": "sha512-XVSklkRRQ/CQDmv3VVFdZRl5hTFgncFhZrLyi0Ai4LZk5o3jpY5HIfuTK7ad7tixPKa+iQmL9+vg9qNyYZB+nw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/nested-clients": "^3.996.5", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/nested-clients": "^3.997.2", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -357,22 +359,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.16.tgz", - "integrity": "sha512-7mlt14Ee4rPFAFUVgpWE7+0CBhetJJyzVFqfIsMp7sgyOSm9Y/+qHZOWAuK5I4JNc+Y5PltvJ9kssTzRo92iXQ==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.35.tgz", + "integrity": "sha512-nVrY7AdGfzYgAa/jd9m06p3ES7QQDaB7zN9c+vXnVXxBRkAs9MjRDPB5AKogWuC6phddltfvHGFqLDJmyU9u/A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.15", - "@aws-sdk/credential-provider-http": "^3.972.17", - "@aws-sdk/credential-provider-ini": "^3.972.15", - "@aws-sdk/credential-provider-process": "^3.972.15", - "@aws-sdk/credential-provider-sso": "^3.972.15", - "@aws-sdk/credential-provider-web-identity": "^3.972.15", - "@aws-sdk/types": "^3.973.4", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/credential-provider-env": "^3.972.30", + "@aws-sdk/credential-provider-http": "^3.972.32", + "@aws-sdk/credential-provider-ini": "^3.972.34", + "@aws-sdk/credential-provider-process": "^3.972.30", + "@aws-sdk/credential-provider-sso": "^3.972.34", + "@aws-sdk/credential-provider-web-identity": "^3.972.34", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -380,16 +382,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.15.tgz", - "integrity": "sha512-PrH3iTeD18y/8uJvQD2s/T87BTGhsdS/1KZU7ReWHXsplBwvCqi7AbnnNbML1pFlQwRWCE2RdSZFWDVId3CvkA==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.30.tgz", + "integrity": "sha512-McJPomNTSEo+C6UA3Zq6pFrcyTUaVsoPPBOvbOHAoIFPc8Z2CMLndqFJOnB+9bVFiBTWQLutlVGmrocBbvv4MQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -397,18 +399,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.15.tgz", - "integrity": "sha512-M/+LBHTPKZxxXckM6m4dnJeR+jlm9NynH9b2YDswN4Zj2St05SK/crdL3Wy3WfJTZootnnhm3oTh87Usl7PS7w==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.34.tgz", + "integrity": "sha512-WngYb2K+/yhkDOmDfAOjoCa9Ja3he0DZiAraboKwgWoVRkajDIcDYBCVbUTxtTUldvQoe7VvHLTrBNxvftN1aQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/nested-clients": "^3.996.5", - "@aws-sdk/token-providers": "3.1002.0", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/nested-clients": "^3.997.2", + "@aws-sdk/token-providers": "3.1035.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -416,17 +418,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.15.tgz", - "integrity": "sha512-QTH6k93v+UOfFam/ado8zc71tH+enTVyuvLy9uEWXX1x894dN5ovtf/MdBDgFwq3g6c9mbtgVJ4B+yBqDtXvdA==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.34.tgz", + "integrity": "sha512-5KLUH+XmSNRj6amJiJSrPsCxU5l/PYDfxyqPa1MxWhHoQC3sxvGPrSib3IE+HQlfRA4e2kO0bnJy7HJdjvpuuA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/nested-clients": "^3.996.5", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/nested-clients": "^3.997.2", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -434,14 +436,14 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.9.tgz", - "integrity": "sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA==", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.14.tgz", + "integrity": "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/eventstream-codec": "^4.2.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -449,14 +451,14 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.6.tgz", - "integrity": "sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.10.tgz", + "integrity": "sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -464,14 +466,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", - "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -479,13 +481,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", - "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -493,15 +495,40 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz", - "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.33.tgz", + "integrity": "sha512-n8Eh/+kq3u/EodLr8n6sQupu03QGjf122RHXCTGLaHSkavz/2beSKpRlq2oDgfmJZNkAkWF113xbyaUmyOd+YA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.16", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.24", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -509,17 +536,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.17.tgz", - "integrity": "sha512-HHArkgWzomuwufXwheQqkddu763PWCpoNTq1dGjqXzJT/lojX3VlOqjNSR2Xvb6/T9ISfwYcMOcbFgUp4EWxXA==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.34.tgz", + "integrity": "sha512-jrmJHyYlTQocR7H4VhvSFhaoedMb2rmlOTvFWD6tNBQ/EVQhTsrNfQUYFuPiOc2wUGxbm5LgCHtnvVmCPgODHw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@smithy/core": "^3.23.7", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@smithy/core": "^3.23.16", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-retry": "^4.3.3", "tslib": "^2.6.2" }, "engines": { @@ -527,22 +555,22 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.11.tgz", - "integrity": "sha512-cWf+8iUUnitgFuUu/ryK2uVfx7f5ezdhGwsjLLEEC1Nk716Ld2Hw4LA8iipyVcQI3EarvK6ExY2dSBET/0PYng==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.16.tgz", + "integrity": "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-format-url": "^3.972.6", - "@smithy/eventstream-codec": "^4.2.10", - "@smithy/eventstream-serde-browser": "^4.2.10", - "@smithy/fetch-http-handler": "^5.3.12", - "@smithy/protocol-http": "^5.3.10", - "@smithy/signature-v4": "^5.3.10", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-format-url": "^3.972.10", + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -550,48 +578,49 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.5.tgz", - "integrity": "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw==", + "version": "3.997.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.2.tgz", + "integrity": "sha512-uGGQO08YetrqfInOKG5atRMrCDRQWRuZ9gGfKY6svPmuE4K7ac+XcbCkpWpjcA7yCYsBaKB/Nly4XKgPXUO1PA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/middleware-host-header": "^3.972.6", - "@aws-sdk/middleware-logger": "^3.972.6", - "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-user-agent": "^3.972.17", - "@aws-sdk/region-config-resolver": "^3.972.6", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.2", - "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.7", - "@smithy/fetch-http-handler": "^5.3.12", - "@smithy/hash-node": "^4.2.10", - "@smithy/invalid-dependency": "^4.2.10", - "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.21", - "@smithy/middleware-retry": "^4.4.38", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.13", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.1", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.37", - "@smithy/util-defaults-mode-node": "^4.2.40", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.34", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.21", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.20", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.16", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.31", + "@smithy/middleware-retry": "^4.5.4", + "@smithy/middleware-serde": "^4.2.19", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@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.48", + "@smithy/util-defaults-mode-node": "^4.2.53", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.3", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -599,15 +628,32 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz", - "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", + "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/config-resolver": "^4.4.9", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.21.tgz", + "integrity": "sha512-3EpT+C0QdmTMB5aVeJ5odWSLt9vg2oGzUXl1xvUazKGlkr9OBYnegNWqhhjGgZdv8RmSi5eS8nqqB+euNP2aqA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.33", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -615,17 +661,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1002.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1002.0.tgz", - "integrity": "sha512-x972uKOydFn4Rb0PZJzLdNW59rH0KWC78Q2JbQzZpGlGt0DxjYdDRwBG6F42B1MyaEwHGqO/tkGc4r3/PRFfMw==", + "version": "3.1035.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1035.0.tgz", + "integrity": "sha512-E6IO3Cn+OzBe6Sb5pnubd5Y8qSUMAsVKkD5QSwFfIx5fV1g5SkYwUDRDyPlm90RuIVcCo28wpMJU6W8wXH46Aw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.17", - "@aws-sdk/nested-clients": "^3.996.5", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.4", + "@aws-sdk/nested-clients": "^3.997.2", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -633,12 +679,24 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", - "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { @@ -646,15 +704,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", - "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-endpoints": "^3.3.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -662,14 +720,14 @@ } }, "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.6.tgz", - "integrity": "sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.10.tgz", + "integrity": "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/querystring-builder": "^4.2.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -677,9 +735,9 @@ } }, "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", - "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -689,27 +747,28 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz", - "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.2.tgz", - "integrity": "sha512-lpaIuekdkpw7VRiik0IZmd6TyvEUcuLgKZ5fKRGpCA3I4PjrD/XH15sSwW+OptxQjNU4DEzSxag70spC9SluvA==", + "version": "3.973.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.20.tgz", + "integrity": "sha512-owEqyKr0z5hWwk+uHwudwNhyFMZ9f9eSWr/k/XD6yeDCI7hHyc56s4UOY1iBQmoramTbdAY4UCuLLEuKmjVXrg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.17", - "@aws-sdk/types": "^3.973.4", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/middleware-user-agent": "^3.972.34", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -725,13 +784,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", - "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz", + "integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.4.1", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, "engines": { @@ -739,18 +798,18 @@ } }, "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1080,34 +1139,33 @@ } }, "node_modules/@mariozechner/pi-agent-core": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.62.0.tgz", - "integrity": "sha512-SBjqgDrgKOaC+IGzFGB3jXQErv9H1QMYnWFvUg6ra6dG0ZgWFBUZb6unidngWLsmaxSDWes6KeKiVFMsr2VSEQ==", + "version": "0.69.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.69.0.tgz", + "integrity": "sha512-dYZjee7QEIwVVbDGCGriiLjDT34IdMlXMjMp4drVKcLEuVc+BztyODwu3w4ZORVvwaySGGlF9UALjJKUVoXiug==", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.62.0" + "@mariozechner/pi-ai": "^0.69.0", + "typebox": "^1.1.24" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@mariozechner/pi-ai": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.62.0.tgz", - "integrity": "sha512-mJgryZ5RgBQG++tiETMtCQQJoH2MAhKetCfqI98NMvGydu7L9x2qC2JekQlRaAgIlTgv4MRH1UXHMEs4UweE/Q==", + "version": "0.69.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.69.0.tgz", + "integrity": "sha512-bl838sr57zx/apkiTeNPFQgbBkGXN4HHhm2oghzSRohJIBrR+H001bTeco7Osuke+EZTxO3V5Aev2qlZdUa2xw==", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.73.0", - "@aws-sdk/client-bedrock-runtime": "^3.983.0", + "@anthropic-ai/sdk": "^0.90.0", + "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@google/genai": "^1.40.0", - "@mistralai/mistralai": "1.14.1", - "@sinclair/typebox": "^0.34.41", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", + "@mistralai/mistralai": "^2.2.0", "chalk": "^5.6.2", "openai": "6.26.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", + "typebox": "^1.1.24", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, @@ -1119,15 +1177,15 @@ } }, "node_modules/@mariozechner/pi-coding-agent": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.62.0.tgz", - "integrity": "sha512-f1NnExqsHuA6w8UVlBtPsvTBhdkMc0h1JD9SzGCdWTLou5GHJr2JIP6DlwV9IKWAnM+sAelaoFez+14wLP2zOQ==", + "version": "0.69.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.69.0.tgz", + "integrity": "sha512-/EFn43RO2TtMgZI4fMXNepIvbWm2GqhhaLIHBkrO7tRbRCzgvDYkw9sMLKhdf1Ihom2CjC3T/fzQRIPasRSIrQ==", "license": "MIT", "dependencies": { "@mariozechner/jiti": "^2.6.2", - "@mariozechner/pi-agent-core": "^0.62.0", - "@mariozechner/pi-ai": "^0.62.0", - "@mariozechner/pi-tui": "^0.62.0", + "@mariozechner/pi-agent-core": "^0.69.0", + "@mariozechner/pi-ai": "^0.69.0", + "@mariozechner/pi-tui": "^0.69.0", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", @@ -1141,7 +1199,9 @@ "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", "strip-ansi": "^7.1.0", + "typebox": "^1.1.24", "undici": "^7.19.1", + "uuid": "^11.1.0", "yaml": "^2.8.2" }, "bin": { @@ -1155,9 +1215,9 @@ } }, "node_modules/@mariozechner/pi-tui": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.62.0.tgz", - "integrity": "sha512-/At11PPe8l319MnUoK4wN5L/uVCU6bDdiIUzH8Ez0stOkjSF6isRXScZ+RMM+6iCKsD4muBTX8Cmcif+3/UWHA==", + "version": "0.69.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.69.0.tgz", + "integrity": "sha512-/82SOMzCA1srivwfcSO8r9XLdAgJNcgpWzNZY33IqqvGev8Q4iWY/hA1wAkLB0l7PKJJ4Dh57xS4IGHbyVM7lA==", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", @@ -1174,13 +1234,14 @@ } }, "node_modules/@mistralai/mistralai": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz", - "integrity": "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "license": "Apache-2.0", "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.24.1" + "zod-to-json-schema": "^3.25.0" } }, "node_modules/@pkgjs/parseargs": { @@ -1269,30 +1330,17 @@ "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", "license": "MIT" }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", - "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/config-resolver": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", - "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", + "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -1300,18 +1348,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.8.tgz", - "integrity": "sha512-f7uPeBi7ehmLT4YF2u9j3qx6lSnurG1DLXOsTtJrIRNDF7VXio4BGHQ+SQteN/BrUVudbkuL4v7oOsRCzq4BqA==", + "version": "3.23.16", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.16.tgz", + "integrity": "sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.12", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@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-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.24", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -1321,15 +1369,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", - "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", + "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", "license": "Apache-2.0", "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", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -1337,13 +1385,13 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.11.tgz", - "integrity": "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" }, @@ -1352,13 +1400,13 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.11.tgz", - "integrity": "sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", + "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1366,12 +1414,12 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.11.tgz", - "integrity": "sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", + "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1379,13 +1427,13 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.11.tgz", - "integrity": "sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", + "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1393,13 +1441,13 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.11.tgz", - "integrity": "sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", + "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1407,14 +1455,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", - "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -1423,12 +1471,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", - "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", + "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -1438,12 +1486,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", - "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", + "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1463,13 +1511,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", - "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", + "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1477,18 +1525,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.22", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.22.tgz", - "integrity": "sha512-sc81w1o4Jy+/MAQlY3sQ8C7CmSpcvIi3TAzXblUv2hjG11BBSJi/Cw8vDx5BxMxapuH2I+Gc+45vWsgU07WZRQ==", + "version": "4.4.31", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.31.tgz", + "integrity": "sha512-KJPdCIN2kOE2aGmqZd7eUTr4WQwOGgtLWgUkswGJggs7rBcQYQjcZMEDa3C0DwbOiXS9L8/wDoQHkfxBYLfiLw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.8", - "@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", + "@smithy/core": "^3.23.16", + "@smithy/middleware-serde": "^4.2.19", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -1496,18 +1544,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.39", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.39.tgz", - "integrity": "sha512-MCVCxaCzuZgiHtHGV2Ke44nh6t4+8/tO+rTYOzrr2+G4nMLU/qbzNCWKBX54lyEaVcGQrfOJiG2f8imtiw+nIQ==", - "license": "Apache-2.0", - "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.2", - "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.4.tgz", + "integrity": "sha512-/z7nIFK+ZRW3Ie/l3NEVGdy34LvmEOzBrtBAvgWZ/4PrKX0xP3kWm8pkfcwUk523SqxZhdbQP9JSXgjF77Uhpw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.16", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/service-error-classification": "^4.3.0", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.3", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -1516,13 +1565,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", - "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.19.tgz", + "integrity": "sha512-Q6y+W9h3iYVMCKWDoVge+OC1LKFqbEKaq8SIWG2X2bWJRpd/6dDLyICcNLT6PbjH3Rr6bmg/SeDB25XFOFfeEw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/core": "^3.23.16", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1530,12 +1580,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", - "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1543,14 +1593,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.11", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", - "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1558,15 +1608,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", - "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.0.tgz", + "integrity": "sha512-P734cAoTFtuGfWa/R3jgBnGlURt2w9bYEBwQNMKf58sRM9RShirB2mKwLsVP+jlG/wxpCu8abv8NxdUts8tdLA==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1574,12 +1623,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", - "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1587,12 +1636,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", - "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1600,12 +1649,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", - "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -1614,12 +1663,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", - "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1627,24 +1676,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", - "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.0.tgz", + "integrity": "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0" + "@smithy/types": "^4.14.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", - "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1652,16 +1701,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", - "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", + "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -1671,17 +1720,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.2.tgz", - "integrity": "sha512-HezY3UuG0k4T+4xhFKctLXCA5N2oN+Rtv+mmL8Gt7YmsUY2yhmcLyW75qrSzldfj75IsCW/4UhY3s20KcFnZqA==", + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.12.tgz", + "integrity": "sha512-daO7SJn4eM6ArbmrEs+/BTbH7af8AEbSL3OMQdcRvvn8tuUcR5rU2n6DgxIV53aXMS42uwK8NgKKCh5XgqYOPQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.8", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.17", + "@smithy/core": "^3.23.16", + "@smithy/middleware-endpoint": "^4.4.31", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.24", "tslib": "^2.6.2" }, "engines": { @@ -1689,9 +1738,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", - "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1701,13 +1750,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", - "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1778,14 +1827,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.38", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.38.tgz", - "integrity": "sha512-c8P1mFLNxcsdAMabB8/VUQUbWzFmgujWi4bAXSggcqLYPc8V4U5abqFqOyn+dK4YT+q8UyCVkTO8807t4t2syA==", + "version": "4.3.48", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.48.tgz", + "integrity": "sha512-hxVRVPYaRDWa6YQdse1aWX1qrksmLsvNyGBKdc32q4jFzSjxYVNWfstknAfR228TnzS4tzgswXRuYIbhXBuXFQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1793,17 +1842,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.41", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.41.tgz", - "integrity": "sha512-/UG+9MT3UZAR0fLzOtMJMfWGcjjHvgggq924x/CRy8vRbL+yFf3Z6vETlvq8vDH92+31P/1gSOFoo7303wN8WQ==", + "version": "4.2.53", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.53.tgz", + "integrity": "sha512-ybgCk+9JdBq8pYC8Y6U5fjyS8e4sboyAShetxPNL0rRBtaVl56GSFAxsolVBIea1tXR4LPIzL8i6xqmcf0+DCQ==", "license": "Apache-2.0", "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.2", - "@smithy/types": "^4.13.0", + "@smithy/config-resolver": "^4.4.17", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1811,13 +1860,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", - "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", + "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1837,12 +1886,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", - "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1850,13 +1899,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", - "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.3.tgz", + "integrity": "sha512-idjUvd4M9Jj6rXkhqw4H4reHoweuK4ZxYWyOrEp4N2rOF5VtaOlQGLDQJva/8WanNXk9ScQtsAb7o5UHGvFm4A==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.11", - "@smithy/types": "^4.13.0", + "@smithy/service-error-classification": "^4.3.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1864,14 +1913,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.17", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", - "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", + "version": "4.5.24", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.24.tgz", + "integrity": "sha512-na5vv2mBSDzXewLEEoWGI7LQQkfpmFEomBsmOpzLFjqGctm0iMwXY5lAwesY9pIaErkccW0qzEOUcYP+WKneXg==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/types": "^4.13.0", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.0", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", @@ -1939,6 +1988,130 @@ "@sinclair/typebox": "latest" } }, + "node_modules/@tintinweb/pi-tasks/node_modules/@anthropic-ai/sdk": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", + "integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@tintinweb/pi-tasks/node_modules/@mariozechner/pi-agent-core": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-agent-core/-/pi-agent-core-0.62.0.tgz", + "integrity": "sha512-SBjqgDrgKOaC+IGzFGB3jXQErv9H1QMYnWFvUg6ra6dG0ZgWFBUZb6unidngWLsmaxSDWes6KeKiVFMsr2VSEQ==", + "license": "MIT", + "dependencies": { + "@mariozechner/pi-ai": "^0.62.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@tintinweb/pi-tasks/node_modules/@mariozechner/pi-ai": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.62.0.tgz", + "integrity": "sha512-mJgryZ5RgBQG++tiETMtCQQJoH2MAhKetCfqI98NMvGydu7L9x2qC2JekQlRaAgIlTgv4MRH1UXHMEs4UweE/Q==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.73.0", + "@aws-sdk/client-bedrock-runtime": "^3.983.0", + "@google/genai": "^1.40.0", + "@mistralai/mistralai": "1.14.1", + "@sinclair/typebox": "^0.34.41", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "chalk": "^5.6.2", + "openai": "6.26.0", + "partial-json": "^0.1.7", + "proxy-agent": "^6.5.0", + "undici": "^7.19.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@tintinweb/pi-tasks/node_modules/@mariozechner/pi-coding-agent": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.62.0.tgz", + "integrity": "sha512-f1NnExqsHuA6w8UVlBtPsvTBhdkMc0h1JD9SzGCdWTLou5GHJr2JIP6DlwV9IKWAnM+sAelaoFez+14wLP2zOQ==", + "license": "MIT", + "dependencies": { + "@mariozechner/jiti": "^2.6.2", + "@mariozechner/pi-agent-core": "^0.62.0", + "@mariozechner/pi-ai": "^0.62.0", + "@mariozechner/pi-tui": "^0.62.0", + "@silvia-odwyer/photon-node": "^0.3.4", + "chalk": "^5.5.0", + "cli-highlight": "^2.1.11", + "diff": "^8.0.2", + "extract-zip": "^2.0.1", + "file-type": "^21.1.1", + "glob": "^13.0.1", + "hosted-git-info": "^9.0.2", + "ignore": "^7.0.5", + "marked": "^15.0.12", + "minimatch": "^10.2.3", + "proper-lockfile": "^4.1.2", + "strip-ansi": "^7.1.0", + "undici": "^7.19.1", + "yaml": "^2.8.2" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=20.6.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.2" + } + }, + "node_modules/@tintinweb/pi-tasks/node_modules/@mariozechner/pi-tui": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.62.0.tgz", + "integrity": "sha512-/At11PPe8l319MnUoK4wN5L/uVCU6bDdiIUzH8Ez0stOkjSF6isRXScZ+RMM+6iCKsD4muBTX8Cmcif+3/UWHA==", + "license": "MIT", + "dependencies": { + "@types/mime-types": "^2.1.4", + "chalk": "^5.5.0", + "get-east-asian-width": "^1.3.0", + "marked": "^15.0.12", + "mime-types": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "koffi": "^2.9.0" + } + }, + "node_modules/@tintinweb/pi-tasks/node_modules/@mistralai/mistralai": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz", + "integrity": "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.24.1" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -2468,21 +2641,24 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", "funding": [ { "type": "github", @@ -2491,8 +2667,9 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -3166,6 +3343,21 @@ "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", "license": "MIT" }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3610,9 +3802,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "funding": [ { "type": "github", @@ -3700,6 +3892,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/typebox": { + "version": "1.1.32", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.32.tgz", + "integrity": "sha512-cbGoj7BCxGcFDJ/RR7wbyMe9IkO2SeNhwLdZWQ+xRtun9+ze9iM1pBND4SoFAxgonuJYrCIWnEQ7sE4bMVDYHA==", + "license": "MIT" + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -3727,6 +3925,19 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -3938,12 +4149,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } } } diff --git a/package.json b/package.json index ecd3a1b..8411f0b 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "version": "0.1.0", "dependencies": { "@benvargas/pi-claude-code-use": "^1.0.1", - "@mariozechner/pi-ai": "^0.62.0", - "@mariozechner/pi-coding-agent": "^0.62.0", - "@mariozechner/pi-tui": "^0.62.0", + "@mariozechner/pi-ai": "^0.69.0", + "@mariozechner/pi-coding-agent": "^0.69.0", + "@mariozechner/pi-tui": "^0.69.0", "@sting8k/pi-vcc": "^0.3.5", - "@tintinweb/pi-tasks": "^0.4.2" + "@tintinweb/pi-tasks": "^0.4.2", + "typebox": "^1.1.32" }, "optionalDependencies": { "@ataraxy-labs/sem": "^0.3.21" @@ -34,7 +35,8 @@ "./extensions/tool-pills/index.ts", "./extensions/cmux/index.ts", "./extensions/superconductor/index.ts", - "./extensions/session-snap/index.ts", + "./extensions/muxy-notify/index.ts", + "./extensions/session-store/index.ts", "./extensions/pi-telescope/index.ts", "./extensions/handoff/index.ts", "./extensions/pi-sem/index.ts" @@ -45,7 +47,8 @@ "./skills/visit-webpage", "./skills/web-search", "./skills/session-query", - "./skills/sem" + "./skills/sem", + "./skills/pr-review-comments" ], "themes": [ "./themes" -- 2.51.2 From cfc6471fc72635a79886a114be7e406f93fc89d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Toms=CC=8Cej?= Date: Sat, 4 Jul 2026 06:46:46 +0200 Subject: [PATCH 32/66] feat(workflow): add OpenSpec + taskflow spec-driven implement loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up a spec-driven development pipeline for Pi: - openspec init --tools pi: /opsx-* prompts + openspec-* skills, openspec/ - two pi-taskflow flows in .pi/taskflows/: - openspec-implement: whole-change self-healing implement→verify→gate - openspec-implement-loop: one task per iteration with fresh context both run build/test + `openspec validate --strict` before an LLM acceptance gate; verify command is a per-project arg - WORKFLOWS.md documents the propose → implement → archive loop - .gitignore: version the shared .pi assets (opsx prompts/skills, taskflow defs) while keeping runtime state (runs, tasks, sessions) ignored - package.json/bun.lock: add pi-taskflow dependency Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011r3X6JYk3amhhsmtBKZidu --- .gitignore | 17 +- .pi/prompts/opsx-apply.md | 153 +++++++++++ .pi/prompts/opsx-archive.md | 158 +++++++++++ .pi/prompts/opsx-explore.md | 172 ++++++++++++ .pi/prompts/opsx-propose.md | 107 ++++++++ .pi/prompts/opsx-sync.md | 141 ++++++++++ .pi/skills/openspec-apply-change/SKILL.md | 159 +++++++++++ .pi/skills/openspec-archive-change/SKILL.md | 117 ++++++++ .pi/skills/openspec-explore/SKILL.md | 289 ++++++++++++++++++++ .pi/skills/openspec-propose/SKILL.md | 113 ++++++++ .pi/skills/openspec-sync-specs/SKILL.md | 147 ++++++++++ .pi/taskflows/openspec-implement-loop.json | 75 +++++ .pi/taskflows/openspec-implement.json | 67 +++++ WORKFLOWS.md | 142 ++++++++++ bun.lock | 187 +++++++++++++ openspec/config.yaml | 20 ++ package.json | 1 + 17 files changed, 2064 insertions(+), 1 deletion(-) create mode 100644 .pi/prompts/opsx-apply.md create mode 100644 .pi/prompts/opsx-archive.md create mode 100644 .pi/prompts/opsx-explore.md create mode 100644 .pi/prompts/opsx-propose.md create mode 100644 .pi/prompts/opsx-sync.md create mode 100644 .pi/skills/openspec-apply-change/SKILL.md create mode 100644 .pi/skills/openspec-archive-change/SKILL.md create mode 100644 .pi/skills/openspec-explore/SKILL.md create mode 100644 .pi/skills/openspec-propose/SKILL.md create mode 100644 .pi/skills/openspec-sync-specs/SKILL.md create mode 100644 .pi/taskflows/openspec-implement-loop.json create mode 100644 .pi/taskflows/openspec-implement.json create mode 100644 WORKFLOWS.md create mode 100644 openspec/config.yaml diff --git a/.gitignore b/.gitignore index 46202f2..327757e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,21 @@ node_modules/ .DS_Store *.log -.pi/ extensions/piff/dist/ .sem/ + +# --- pi state --- +# Ignore all of .pi/ by default, then re-include the shared, version-controlled +# assets (OpenSpec opsx prompts/skills + taskflow definitions). A child can't be +# re-added while an ancestor dir stays ignored, so we un-ignore .pi/ itself and +# re-ignore only the runtime-state subdirs. +.pi/* +!.pi/prompts/ +!.pi/skills/ +!.pi/taskflows/ +.pi/taskflows/* +!.pi/taskflows/*.json + +# Nested pi runtime state that appears in subdirectories (e.g. task stores +# created while running pi from inside extensions/). Only the root .pi/ is shared. +**/*/.pi/ diff --git a/.pi/prompts/opsx-apply.md b/.pi/prompts/opsx-apply.md new file mode 100644 index 0000000..ce2942d --- /dev/null +++ b/.pi/prompts/opsx-apply.md @@ -0,0 +1,153 @@ +--- +description: Implement tasks from an OpenSpec change (Experimental) +--- + +Implement tasks from an OpenSpec change. + +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + +**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. +**Provided arguments**: $@ + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx-apply `). + +2. **Check status to understand the schema** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read every file path listed under `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx-archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.