diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c1c4912 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Cargo test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Run workspace tests + run: cargo test --workspace diff --git a/.gitignore b/.gitignore index f949afc..5204ca9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ task-*.log release-*.log .claude/* target/* + +# Static frontend deployment bundle +.deploy/ diff --git a/.issue-assets/copy.html b/.issue-assets/copy.html new file mode 100644 index 0000000..30f5e65 --- /dev/null +++ b/.issue-assets/copy.html @@ -0,0 +1,23 @@ + + +Copy screenshot + + + + diff --git a/.issue-assets/rpi-interactive.png b/.issue-assets/rpi-interactive.png new file mode 100644 index 0000000..8a4dbd3 Binary files /dev/null and b/.issue-assets/rpi-interactive.png differ diff --git a/.issue-assets/rpi-working.png b/.issue-assets/rpi-working.png new file mode 100644 index 0000000..8615868 Binary files /dev/null and b/.issue-assets/rpi-working.png differ diff --git a/Cargo.lock b/Cargo.lock index 279f4cc..e9fe1e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1044,7 +1044,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minimal" -version = "0.1.5" +version = "0.1.6" dependencies = [ "futures", "rpi-agent", @@ -1344,7 +1344,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "plugin-stub" -version = "0.1.5" +version = "0.1.6" dependencies = [ "rpi-plugin-sdk", "serde_json", @@ -1387,6 +1387,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -1620,7 +1631,7 @@ dependencies = [ [[package]] name = "rpi-agent" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "futures", @@ -1637,7 +1648,7 @@ dependencies = [ [[package]] name = "rpi-ai" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "base64", @@ -1659,7 +1670,7 @@ dependencies = [ [[package]] name = "rpi-cli" -version = "0.1.5" +version = "0.1.6" dependencies = [ "arboard", "async-trait", @@ -1689,7 +1700,7 @@ dependencies = [ [[package]] name = "rpi-extensions" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "futures", @@ -1707,7 +1718,7 @@ dependencies = [ [[package]] name = "rpi-harness" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "futures", @@ -1727,14 +1738,14 @@ dependencies = [ [[package]] name = "rpi-plugin-sdk" -version = "0.1.5" +version = "0.1.6" dependencies = [ "serde_json", ] [[package]] name = "rpi-telemetry" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "tokio", @@ -1742,7 +1753,7 @@ dependencies = [ [[package]] name = "rpi-tools" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "base64", @@ -1769,10 +1780,11 @@ dependencies = [ [[package]] name = "rpi-tui" -version = "0.1.5" +version = "0.1.6" dependencies = [ "base64", "crossterm", + "pulldown-cmark", "regex", "similar", "thiserror 1.0.69", @@ -2266,7 +2278,7 @@ dependencies = [ [[package]] name = "tools-example" -version = "0.1.5" +version = "0.1.6" dependencies = [ "futures", "rpi-agent", @@ -2360,13 +2372,19 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui-example" -version = "0.1.5" +version = "0.1.6" dependencies = [ "crossterm", "rpi-tui", "tokio", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 8b0773c..5b3c392 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ edition = "2021" rust-version = "1.78" license = "MIT" repository = "https://github.com/bigfish193/pi-rust" -version = "0.1.5" +version = "0.1.6" authors = ["BigFish "] [workspace.dependencies] @@ -65,16 +65,16 @@ rpassword = "7" # pi-rust internal crates (published under the `rpi-` prefix; directories keep # the `pi-` name — package `name` is what crates.io + `extern crate` see). -rpi-telemetry = { path = "crates/pi-telemetry", version = "0.1.5" } -rpi-ai = { path = "crates/pi-ai", version = "0.1.5" } -rpi-agent = { path = "crates/pi-agent", version = "0.1.5" } -rpi-tools = { path = "crates/pi-tools", version = "0.1.5" } -rpi-harness = { path = "crates/pi-harness", version = "0.1.5" } +rpi-telemetry = { path = "crates/pi-telemetry", version = "0.1.6" } +rpi-ai = { path = "crates/pi-ai", version = "0.1.6" } +rpi-agent = { path = "crates/pi-agent", version = "0.1.6" } +rpi-tools = { path = "crates/pi-tools", version = "0.1.6" } +rpi-harness = { path = "crates/pi-harness", version = "0.1.6" } -rpi-tui = { path = "crates/pi-tui", version = "0.1.5" } +rpi-tui = { path = "crates/pi-tui", version = "0.1.6" } -rpi-plugin-sdk = { path = "crates/rpi-plugin-sdk", version = "0.1.5" } -rpi-extensions = { path = "crates/rpi-extensions", version = "0.1.5" } +rpi-plugin-sdk = { path = "crates/rpi-plugin-sdk", version = "0.1.6" } +rpi-extensions = { path = "crates/rpi-extensions", version = "0.1.6" } [profile.release] lto = "thin" diff --git a/README.md b/README.md index a5d6619..fc31393 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # rpi — Rust port of the Pi agent SDK +[![rpi-cli on crates.io](https://img.shields.io/crates/v/rpi-cli.svg)](https://crates.io/crates/rpi-cli) +[![rpi-plugin-sdk docs](https://docs.rs/rpi-plugin-sdk/badge.svg)](https://docs.rs/rpi-plugin-sdk) +[![CI](https://github.com/bigfish1913/pi-rust/actions/workflows/ci.yml/badge.svg)](https://github.com/bigfish1913/pi-rust/actions) + A Rust port of [earendil-works/pi](https://github.com/earendil-works/pi)'s SDK layer — a library-first, multi-crate workspace for building personal LLM coding agents in Rust, plus an `rpi` CLI built on top. @@ -19,9 +23,25 @@ agents in Rust, plus an `rpi` CLI built on top. | `rpi-tools` | `pi-tools/` | Built-in tools (`read`/`write`/`edit`/`bash`/`grep`/`find`/`ls`) + `ExecutionEnv`. | | `rpi-harness` | `pi-harness/` | `AgentHarness`: session tree, JSONL persistence, compaction, run loop. | | `rpi-cli` | `pi-cli/` | Terminal coding-agent CLI (`rpi` binary) on top of the library crates. | +| `rpi-plugin-sdk` | `rpi-plugin-sdk/` | Stable C ABI for Rust-native plugins and extension discovery. | +| `rpi-extensions` | `rpi-extensions/` | Dynamic plugin loader and `AgentTool` adapter. | +| `rpi-tui` | `pi-tui/` | Terminal UI primitives used by the interactive CLI. | Dependency direction: `rpi-telemetry → rpi-ai → rpi-agent → rpi-tools → rpi-harness → rpi-cli`. +### Rust registry links + +| Package | crates.io | docs.rs | +| --- | --- | --- | +| `rpi-cli` | [crates.io](https://crates.io/crates/rpi-cli) | [docs.rs](https://docs.rs/rpi-cli) | +| `rpi-agent` | [crates.io](https://crates.io/crates/rpi-agent) | [docs.rs](https://docs.rs/rpi-agent) | +| `rpi-plugin-sdk` | [crates.io](https://crates.io/crates/rpi-plugin-sdk) | [docs.rs](https://docs.rs/rpi-plugin-sdk) | +| `rpi-extensions` | [crates.io](https://crates.io/crates/rpi-extensions) | [docs.rs](https://docs.rs/rpi-extensions) | + +The registry pages are the canonical entry points for installing the CLI or +embedding the SDK. The repository may contain unreleased changes; check the +published version shown on crates.io before depending on a new API. + ## Relationship to the TypeScript source The TypeScript reference is checked out under `.reference/pi/` (read-only). Every @@ -55,6 +75,29 @@ agent.prompt("Hello!").await.unwrap(); See [docs/architecture.md](docs/architecture.md) for the full design. +## Build a plugin + +Plugins are Rust `cdylib` libraries loaded through the stable ABI exposed by +`rpi-plugin-sdk`. The repository includes a complete `echo` tool example that +also exercises event and resource discovery: + +```bash +cargo build -p plugin-stub +``` + +Then point the CLI at the directory containing the generated library (the +extension is named `plugin_stub.dll`, `libplugin_stub.so`, or +`libplugin_stub.dylib` depending on the platform): + +```bash +rpi --extensions-dir target/debug -p 'echo "hi"' +``` + +The plugin depends on `rpi-plugin-sdk` only; the host-side loader lives in +`rpi-extensions`. See [`examples/plugin-stub`](examples/plugin-stub) and the +[`rpi-plugin-sdk` API docs](https://docs.rs/rpi-plugin-sdk) for the ABI +contract. + ## Status (v1) - **Providers:** Anthropic Messages and OpenAI-compatible Chat Completions, @@ -131,8 +174,8 @@ Publish the crate family in dependency order with `cargo publish` (run publish-scoped token; crates.io records are permanent): ``` -# dep order: telemetry → ai → agent → tools → harness → cli -for c in rpi-telemetry rpi-ai rpi-agent rpi-tools rpi-harness rpi-cli; do +# dep order: telemetry → ai → agent → tools → harness → plugin-sdk → extensions → tui → cli +for c in rpi-telemetry rpi-ai rpi-agent rpi-tools rpi-harness rpi-plugin-sdk rpi-extensions rpi-tui rpi-cli; do cargo publish -p "$c" done ``` diff --git a/Taskfile.yml b/Taskfile.yml index 8b371ab..cf42fed 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -22,6 +22,11 @@ version: '3' +vars: + RPI_SERVICE_NAME: rpi + RPI_DOMAIN: rpi.laofu.online + RPI_DEPLOY_DIR: .deploy/rpi + tasks: default: desc: List available release targets @@ -72,3 +77,79 @@ tasks: desc: cargo test --workspace (the release pre-flight, run standalone) cmds: - cargo test --workspace + + rpi-pack-frontend: + desc: 打包 rpi 静态官网 + cmds: + - >- + powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '{{.RPI_DEPLOY_DIR}}' | Out-Null; Compress-Archive -Path 'website\\*' -DestinationPath '{{.RPI_DEPLOY_DIR}}\\frontend.zip' -Force" + - >- + echo "前端包已生成: {{.RPI_DEPLOY_DIR}}/frontend.zip" + + rpi-upload-frontend: + desc: 上传 rpi 静态官网到公网服务器 + deps: + - rpi-pack-frontend + cmds: + - ssh root@139.196.85.175 "mkdir -p /var/www/{{.RPI_SERVICE_NAME}} /tmp/deploy" + - scp "{{.RPI_DEPLOY_DIR}}/frontend.zip" root@139.196.85.175:/tmp/deploy/{{.RPI_SERVICE_NAME}}-frontend.zip + - ssh root@139.196.85.175 "unzip -o /tmp/deploy/{{.RPI_SERVICE_NAME}}-frontend.zip -d /var/www/{{.RPI_SERVICE_NAME}} >/dev/null && rm /tmp/deploy/{{.RPI_SERVICE_NAME}}-frontend.zip" + - >- + echo "前端文件已上传到 /var/www/{{.RPI_SERVICE_NAME}}/" + + rpi-caddy-upload: + desc: 上传 rpi Caddy 配置并重载 + cmds: + - scp deploy/rpi.caddy root@139.196.85.175:/root/work/ai-infr/config/caddy/sites/{{.RPI_SERVICE_NAME}}.caddy + - ssh root@139.196.85.175 "docker exec app-caddy sh -c 'mkdir -p /data/caddy && chmod 755 /data/caddy'" + - ssh root@139.196.85.175 "docker exec app-caddy caddy validate --config /etc/caddy/Caddyfile && docker exec app-caddy caddy reload --config /etc/caddy/Caddyfile" + - >- + echo "Caddy 配置已生效: {{.RPI_DOMAIN}}" + + rpi-caddy-mount: + desc: 将 rpi 静态目录挂载到现有 Caddy 容器 + cmds: + - scp deploy/caddy-rpi.override.yml root@139.196.85.175:/root/work/ai-infr/docker-compose.rpi.override.yml + - ssh root@139.196.85.175 "cd /root/work/ai-infr && docker compose -f docker-compose.yml -f docker-compose.rpi.override.yml config >/dev/null" + - ssh root@139.196.85.175 "docker rm -f app-caddy 2>/dev/null || true" + - ssh root@139.196.85.175 "cd /root/work/ai-infr && docker compose -f docker-compose.yml -f docker-compose.rpi.override.yml up -d caddy" + - powershell -NoProfile -Command "Start-Sleep -Seconds 5" + + rpi-caddy-storage-repair: + desc: 恢复 Caddy 缺失的绑定存储目录并重启容器 + cmds: + - ssh root@139.196.85.175 "mkdir -p /var/lib/docker/volumes/app-caddy-data/_data /var/lib/docker/volumes/app-caddy-config/_data" + - ssh root@139.196.85.175 "docker restart app-caddy" + - powershell -NoProfile -Command "Start-Sleep -Seconds 5" + + rpi-deploy: + desc: 打包、上传并发布 rpi 静态官网 + cmds: + - task: rpi-upload-frontend + - task: rpi-caddy-mount + - task: rpi-caddy-upload + - task: rpi-verify + + rpi-verify: + desc: 验证 rpi 公网首页和静态 JSON + cmds: + - ssh root@139.196.85.175 "curl --fail --silent --show-error --head https://{{.RPI_DOMAIN}}/" + - ssh root@139.196.85.175 "curl --fail --silent --show-error https://{{.RPI_DOMAIN}}/data/packages.json" + - ssh root@139.196.85.175 "curl --fail --silent --show-error https://{{.RPI_DOMAIN}}/index.html | grep -F 'bigfish1913/pi-rust'" + + rpi-diagnose: + desc: 诊断 rpi 公网 HTTP 与 Caddy 状态 + cmds: + - ssh root@139.196.85.175 "curl --max-time 15 --silent --show-error --head http://{{.RPI_DOMAIN}}/" + - ssh root@139.196.85.175 "docker logs app-caddy --tail 80 2>&1 | grep -i '{{.RPI_DOMAIN}}' || true" + - ssh root@139.196.85.175 "docker inspect app-caddy | head -c 5000" + - ssh root@139.196.85.175 "docker exec app-caddy sh -c 'ls -ld /data /config /etc/caddy 2>&1 || true'" + - ssh root@139.196.85.175 "docker exec app-caddy sh -c 'id; stat /data /config; test -w /config && echo config-writable || echo config-not-writable'" + - ssh root@139.196.85.175 "docker volume inspect app-caddy-data app-caddy-config || true" + - ssh root@139.196.85.175 "ls -ld /var/lib/docker/volumes/app-caddy-data/_data /var/lib/docker/volumes/app-caddy-config/_data 2>&1 || true; ls -la /var/lib/docker/volumes/app-caddy-data/_data /var/lib/docker/volumes/app-caddy-config/_data 2>&1 || true" + - ssh root@139.196.85.175 "docker info | grep -E 'Docker Root Dir|Storage Driver'; ls -ld /var/lib/docker /var/lib/docker/volumes" + - ssh root@139.196.85.175 "find /var/www/rpi -maxdepth 2 -type f -printf '%P\\n' | sort | head -80" + - ssh root@139.196.85.175 "curl --insecure --silent --show-error --head https://{{.RPI_DOMAIN}}/index.html; curl --insecure --silent --show-error https://{{.RPI_DOMAIN}}/data/packages.json" + - ssh root@139.196.85.175 "find /root/work/ai-infr -maxdepth 4 -type f \( -name 'docker-compose*.yml' -o -name 'compose*.yml' \) -print" + - ssh root@139.196.85.175 "grep -R -n 'app-caddy' /root/work/ai-infr --include='*.yml' --include='*.yaml' 2>/dev/null | head -40" + - ssh root@139.196.85.175 "sed -n '170,230p' /root/work/ai-infr/docker-compose.yml" diff --git a/crates/pi-ai/src/providers/anthropic/mod.rs b/crates/pi-ai/src/providers/anthropic/mod.rs index 8d19e03..41b281d 100644 --- a/crates/pi-ai/src/providers/anthropic/mod.rs +++ b/crates/pi-ai/src/providers/anthropic/mod.rs @@ -27,7 +27,7 @@ use crate::event_stream::{ }; use crate::model::{Model, StreamingProtocolCompat}; use crate::provider::{CacheRetention, Provider, SimpleStreamOptions}; -use crate::types::{Context, Usage}; +use crate::types::{AssistantMessageEvent, Content, Context, DoneReason, StopReason, Usage}; use async_trait::async_trait; use std::sync::Arc; @@ -189,7 +189,7 @@ async fn run_anthropic_stream( // ---- build params (is_oauth = false in v1) ---- let built = build_params(model, ctx, false, opts); - let body = match serde_json::to_value(&built.request) { + let mut body = match serde_json::to_value(&built.request) { Ok(v) => v, Err(e) => { emit_terminal_error( @@ -201,6 +201,15 @@ async fn run_anthropic_stream( return; } }; + let non_stream = std::env::var("RPI_ANTHROPIC_NON_STREAM") + .ok() + .as_deref() + == Some("1"); + if non_stream { + body["stream"] = serde_json::Value::Bool(false); + strip_cache_control(&mut body); + simplify_non_stream_request(&mut body); + } // ---- assemble headers (createClient, API-key arm) ---- let headers = assemble_headers( @@ -270,11 +279,103 @@ async fn run_anthropic_stream( Ok(r) => r, Err(err) => { let aborted = err.is_abort(); + eprintln!("anthropic request failed: {err}"); emit_terminal_error(prod, &mut state, err.to_string(), aborted); return; } }; + if non_stream { + let response_body = match response.text().await { + Ok(body) => body, + Err(error) => { + emit_terminal_error( + prod, + &mut state, + format!("failed to read non-stream response: {error}"), + false, + ); + return; + } + }; + let value: serde_json::Value = match serde_json::from_str(&response_body) { + Ok(value) => value, + Err(error) => { + emit_terminal_error( + prod, + &mut state, + format!("failed to parse non-stream response: {error}"), + false, + ); + return; + } + }; + let text = value + .get("content") + .and_then(serde_json::Value::as_array) + .map(|blocks| { + blocks + .iter() + .filter(|block| block.get("type").and_then(serde_json::Value::as_str) == Some("text")) + .filter_map(|block| block.get("text").and_then(serde_json::Value::as_str)) + .collect::() + }) + .unwrap_or_default(); + if text.trim().is_empty() { + emit_terminal_error( + prod, + &mut state, + "non-stream response contained no text content".into(), + false, + ); + return; + } + state.output.content.push(Content::text(text.clone())); + state.output.response_id = value + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + state.output.response_model = value + .get("model") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + if let Some(usage) = value.get("usage") { + state.output.usage.input = usage + .get("input_tokens") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + state.output.usage.output = usage + .get("output_tokens") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + state.output.usage.total_tokens = state.output.usage.input + state.output.usage.output; + } + state.output.stop_reason = StopReason::Stop; + let partial = std::sync::Arc::new(state.output.clone()); + prod.push(AssistantMessageEvent::Start { + partial: partial.clone(), + }); + prod.push(AssistantMessageEvent::TextStart { + content_index: 0, + partial: partial.clone(), + }); + prod.push(AssistantMessageEvent::TextDelta { + content_index: 0, + delta: text.clone(), + partial: partial.clone(), + }); + prod.push(AssistantMessageEvent::TextEnd { + content_index: 0, + content: text, + partial, + }); + prod.push(AssistantMessageEvent::Done { + reason: DoneReason::Stop, + message: state.output, + }); + return; + } + // ---- push start, drive SSE → mapper → terminal ---- // The TS pushes `start` here right before the event loop; the Rust mapper // defers `start` to the first event (`ensure_started`) so a pre-stream @@ -287,6 +388,51 @@ async fn run_anthropic_stream( run_mapper(&mut sse, prod, &mut state, cost_fn).await; } +fn strip_cache_control(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + map.remove("cache_control"); + for child in map.values_mut() { + strip_cache_control(child); + } + } + serde_json::Value::Array(items) => { + for child in items { + strip_cache_control(child); + } + } + _ => {} + } +} + +fn simplify_non_stream_request(value: &mut serde_json::Value) { + let text_from_blocks = |blocks: &serde_json::Value| { + blocks + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.get("text").and_then(serde_json::Value::as_str)) + .collect::() + }) + .unwrap_or_default() + }; + if let Some(messages) = value.get_mut("messages").and_then(serde_json::Value::as_array_mut) { + for message in messages { + if let Some(content) = message.get_mut("content") { + if content.is_array() { + *content = serde_json::Value::String(text_from_blocks(content)); + } + } + } + } + if let Some(system) = value.get_mut("system") { + if system.is_array() { + *system = serde_json::Value::String(text_from_blocks(system)); + } + } +} + // ---------------------------------------------------------------------------- // Header + auth helpers — mirrors createClient (API-key arm) + assertRequestAuth. // ---------------------------------------------------------------------------- @@ -333,10 +479,12 @@ fn assemble_headers( ) -> Vec<(String, String)> { let mut headers: Vec<(String, String)> = Vec::new(); headers.push(("accept".into(), "application/json".into())); - headers.push(( - "anthropic-dangerous-direct-browser-access".into(), - "true".into(), - )); + if std::env::var("RPI_ANTHROPIC_NON_STREAM").ok().as_deref() != Some("1") { + headers.push(( + "anthropic-dangerous-direct-browser-access".into(), + "true".into(), + )); + } headers.push(("anthropic-version".into(), ANTHROPIC_VERSION.into())); if let Some(beta) = beta_header { headers.push(("anthropic-beta".into(), beta.into())); diff --git a/crates/pi-cli/src/interactive_tui.rs b/crates/pi-cli/src/interactive_tui.rs index 1a950ac..9dff087 100644 --- a/crates/pi-cli/src/interactive_tui.rs +++ b/crates/pi-cli/src/interactive_tui.rs @@ -1287,10 +1287,6 @@ fn register_extension_commands( /// main async loop. enum TuiMessage { UserInput(String), - QueueInput { - prompt: String, - follow_up: bool, - }, OpenTree, NavigateTree(String), Exit, @@ -3072,10 +3068,27 @@ pub async fn interactive_tui( return; } - if *ctx_for_cb.state.status.lock().unwrap() != RunStatus::Idle { - let _ = ctx_for_cb.tx.send(TuiMessage::QueueInput { - prompt: text.to_string(), - follow_up: false, + let run_status = *ctx_for_cb.state.status.lock().unwrap(); + if run_status != RunStatus::Idle { + let message = AgentMessage::User(UserMessage::new(text.to_string(), 0)); + let aborting = run_status == RunStatus::Aborting; + let lane = ctx_for_cb.lane.clone(); + let chat = ctx_for_cb.chat.clone(); + let tui = ctx_for_cb.tui.clone(); + tokio::spawn(async move { + // Queue immediately while the agent loop is still running. + // Routing this through the TUI's main channel delayed it until + // `prompt_text()` returned, after the loop's drain points had + // passed, so the queued message appeared to disappear. + let result = if aborting { + lane.next_run(message).await + } else { + lane.steer(message).await + }; + if let Err(error) = result { + add_error_message(&chat, &format!("Could not queue message: {error}")); + tui.request_render(false); + } }); add_note_message( &ctx_for_cb.chat, @@ -3509,9 +3522,15 @@ pub async fn interactive_tui( &state_for_key.chat_container, &format!("Queued follow-up message: {prompt}"), ); - let _ = tx_for_key.send(TuiMessage::QueueInput { - prompt, - follow_up: true, + let message = AgentMessage::User(UserMessage::new(prompt, 0)); + let lane = lane_for_key.clone(); + let chat = state_for_key.chat_container.clone(); + let tui = tui_for_key.clone(); + tokio::spawn(async move { + if let Err(error) = lane.follow_up(message).await { + add_error_message(&chat, &format!("Could not queue message: {error}")); + tui.request_render(false); + } }); } tui_for_key.request_render(false); @@ -3556,26 +3575,6 @@ pub async fn interactive_tui( editor.clear(); run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await; } - Some(TuiMessage::QueueInput { prompt, follow_up }) => { - let message = AgentMessage::User(UserMessage::new(prompt.clone(), 0)); - let aborting = *state.status.lock().unwrap() == RunStatus::Aborting; - let result = if aborting { - // An aborting run will not reach either agent-loop drain - // point, so preserve the message for the next run. - lane.next_run(message).await - } else if follow_up { - lane.follow_up(message).await - } else { - lane.steer(message).await - }; - if let Err(error) = result { - add_error_message( - &chat_container, - &format!("Could not queue message: {error}"), - ); - tui.request_render(false); - } - } Some(TuiMessage::OpenTree) => { if *state.status.lock().unwrap() != RunStatus::Idle { add_note_message( @@ -4057,6 +4056,7 @@ async fn handle_agent_event( } => { if let AgentMessage::Assistant(a) = &message { let text = assistant_text(a); + let mut saw_bash_tool_call = false; // Scan content for finalized tool calls → proactively create // tool components (TS shows the tool as soon as the assistant // emits the ToolCall; ToolExecutionStart coalesces if it @@ -4064,6 +4064,7 @@ async fn handle_agent_event( for c in &a.content { if let Content::ToolCall(tc) = c { if tc.name == "bash" { + saw_bash_tool_call = true; // Bash has a dedicated component. Create it here as // well as on ToolExecutionStart because the tool // call can become visible in a MessageUpdate first. @@ -4094,6 +4095,13 @@ async fn handle_agent_event( } } } + // MessageUpdate can expose the finalized bash call before + // ToolExecutionStart arrives. Hide the global `Working…` + // loader immediately when creating that bash panel; otherwise + // it briefly appears alongside the panel's `Running…` spinner. + if saw_bash_tool_call { + state.sync_working_loader_with_bash(); + } let _ = assistant_message_event; // snapshot already applied via `a` if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() { // Stream the full block list (text + thinking) each update diff --git a/crates/pi-tui/Cargo.toml b/crates/pi-tui/Cargo.toml index bebbbd6..b893d61 100644 --- a/crates/pi-tui/Cargo.toml +++ b/crates/pi-tui/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true authors.workspace = true repository.workspace = true description = "Terminal User Interface for pi-rust" +readme = "../../README.md" +keywords = ["agent", "llm", "ai", "terminal", "tui"] +categories = ["command-line-interface", "api-bindings"] [dependencies] crossterm = { version = "0.27", features = ["events"] } @@ -17,10 +20,11 @@ tracing.workspace = true regex = "1" base64 = "0.22" similar = { version = "2", features = ["text", "unicode"] } +pulldown-cmark = { version = "0.12", default-features = false } [features] default = [] image-support = [] [dev-dependencies] -tokio = { workspace = true, features = ["rt", "macros"] } \ No newline at end of file +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/crates/pi-tui/src/markdown.rs b/crates/pi-tui/src/markdown.rs index 29149cc..2d2b744 100644 --- a/crates/pi-tui/src/markdown.rs +++ b/crates/pi-tui/src/markdown.rs @@ -7,6 +7,8 @@ use std::any::Any; use std::sync::Mutex; +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + use super::component::Component; use super::mermaid::Mermaid; use crate::ansi::{bold, italic, underline}; @@ -207,53 +209,35 @@ impl Markdown { } // ---- Headers ---- - // pi colors the whole heading (prefix + text) with mdHeading and - // bolds it; h1/h2 are additionally underlined. + // Headings are rendered with the mdHeading color and bold text; + // h1/h2 are additionally underlined. Markdown markers are hidden. if let Some(rest) = strip_header(line, "######") { list_continuation_indent = None; - let h = bold( - &colors - .md_heading - .fg(&format!("###### {}", self.render_inline(rest))), - ); + let h = bold(&colors.md_heading.fg(&self.render_inline(rest))); push_wrapped(&pad, &h, cwidth, &mut lines); } else if let Some(rest) = strip_header(line, "#####") { list_continuation_indent = None; - let h = bold( - &colors - .md_heading - .fg(&format!("##### {}", self.render_inline(rest))), - ); + let h = bold(&colors.md_heading.fg(&self.render_inline(rest))); push_wrapped(&pad, &h, cwidth, &mut lines); } else if let Some(rest) = strip_header(line, "####") { list_continuation_indent = None; - let h = bold( - &colors - .md_heading - .fg(&format!("#### {}", self.render_inline(rest))), - ); + let h = bold(&colors.md_heading.fg(&self.render_inline(rest))); push_wrapped(&pad, &h, cwidth, &mut lines); } else if let Some(rest) = strip_header(line, "###") { list_continuation_indent = None; - // Keep the heading marker visible at every level. H1/H2 - // already do this; omitting `### ` here made H3 look like a - // regular bold paragraph and made streamed markdown jump - // between styles. - let h = bold( - &colors - .md_heading - .fg(&format!("### {}", self.render_inline(rest))), - ); + let h = bold(&colors.md_heading.fg(&self.render_inline(rest))); push_wrapped(&pad, &h, cwidth, &mut lines); } else if let Some(rest) = strip_header(line, "##") { list_continuation_indent = None; - let inner = bold(&underline(&self.render_inline(rest))); - let h = colors.md_heading.fg(&format!("{}{}", bold("## "), inner)); + let h = colors + .md_heading + .fg(&bold(&underline(&self.render_inline(rest)))); push_wrapped(&pad, &h, cwidth, &mut lines); } else if let Some(rest) = strip_header(line, "#") { list_continuation_indent = None; - let inner = bold(&underline(&bold(&self.render_inline(rest)))); - let h = colors.md_heading.fg(&format!("{}{}", bold("# "), inner)); + let h = colors + .md_heading + .fg(&bold(&underline(&self.render_inline(rest)))); push_wrapped(&pad, &h, cwidth, &mut lines); } else if let Some(item) = parse_list_item(line) { let marker = match item.marker { @@ -319,136 +303,161 @@ impl Markdown { lines } - /// Render inline markdown (bold, italic, code, links). + /// Render inline CommonMark/GFM with terminal-specific ANSI styling. fn render_inline(&self, text: &str) -> String { let colors = theme().colors; - let mut result = String::new(); - let mut offset = 0; - - while offset < text.len() { - let rest = &text[offset..]; - - if let Some(escaped) = rest.strip_prefix('\\') { - if let Some(ch) = escaped.chars().next() { - result.push(ch); - offset += 1 + ch.len_utf8(); - } else { - result.push('\\'); - offset += 1; + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + + // pulldown-cmark parses complete documents and has no inline-only + // entry point. A plain prefix keeps block-looking text in inline + // context; it is discarded from the first text event below. + let source = format!("x {text}"); + let mut discard_prefix = true; + let mut frames = vec![InlineFrame::root()]; + + for event in Parser::new_ext(&source, options) { + match event { + Event::Start(Tag::Strong) => frames.push(InlineFrame::new(InlineKind::Bold)), + Event::Start(Tag::Emphasis) => frames.push(InlineFrame::new(InlineKind::Italic)), + Event::Start(Tag::Strikethrough) => { + frames.push(InlineFrame::new(InlineKind::Strike)); } - continue; - } - - if rest.starts_with('`') { - let ticks = rest.chars().take_while(|c| *c == '`').count(); - let delimiter = "`".repeat(ticks); - if let Some(end) = rest[ticks..].find(&delimiter) { - let code = &rest[ticks..ticks + end]; - let chip = format!(" {} ", colors.md_code.fg(code.trim())); - result.push_str(&colors.md_code_bg.bg(&chip)); - offset += ticks + end + ticks; - continue; + Event::Start(Tag::Link { dest_url, .. }) => { + frames.push(InlineFrame::new(InlineKind::Link(dest_url.into_string()))) } - } - - if rest.starts_with('[') { - if let Some(label_end) = rest.find("](") { - let url_start = label_end + 2; - if let Some(url_end) = matching_paren(&rest[url_start..]) { - let label = &rest[1..label_end]; - let url = &rest[url_start..url_start + url_end]; - let styled = colors.md_link.fg(&underline(&self.render_inline(label))); - result.push_str(&styled); - if label != url { - result.push_str(&colors.md_link_url.fg(&format!(" ({url})"))); - } - offset += url_start + url_end + 1; - continue; - } + Event::Start(Tag::Image { dest_url, .. }) => { + frames.push(InlineFrame::new(InlineKind::Image(dest_url.into_string()))) } - } - - if let Some((delimiter, style)) = emphasis_delimiter(rest) { - if let Some(end) = rest[delimiter.len()..].find(delimiter) { - let inner = &rest[delimiter.len()..delimiter.len() + end]; - if !inner.is_empty() { - let rendered = self.render_inline(inner); - match style { - InlineStyle::Bold => result.push_str(&bold(&rendered)), - InlineStyle::Italic => result.push_str(&italic(&rendered)), - InlineStyle::Strike => { - result.push_str("\x1b[9m"); - result.push_str(&rendered); - result.push_str("\x1b[29m"); - } - } - offset += delimiter.len() + end + delimiter.len(); - continue; + Event::End( + TagEnd::Strong + | TagEnd::Emphasis + | TagEnd::Strikethrough + | TagEnd::Link + | TagEnd::Image, + ) => close_inline_frame(&mut frames, &colors), + Event::Text(value) => { + let mut value = value.as_ref(); + if discard_prefix { + value = value.strip_prefix("x ").unwrap_or(value); + discard_prefix = false; } + frames + .last_mut() + .expect("root inline frame") + .text + .push_str(value); + } + Event::Code(code) => { + let chip = format!(" {} ", colors.md_code.fg(code.trim())); + frames + .last_mut() + .expect("root inline frame") + .text + .push_str(&colors.md_code_bg.bg(&chip)); } + Event::InlineMath(value) => frames + .last_mut() + .expect("root inline frame") + .text + .push_str(&format!("${value}$")), + Event::DisplayMath(value) => frames + .last_mut() + .expect("root inline frame") + .text + .push_str(&format!("$${value}$$")), + Event::Html(value) | Event::InlineHtml(value) => frames + .last_mut() + .expect("root inline frame") + .text + .push_str(&value), + Event::FootnoteReference(label) => frames + .last_mut() + .expect("root inline frame") + .text + .push_str(&format!("[^{label}]")), + Event::SoftBreak => frames.last_mut().expect("root inline frame").text.push(' '), + Event::HardBreak => frames + .last_mut() + .expect("root inline frame") + .text + .push('\n'), + Event::TaskListMarker(checked) => frames + .last_mut() + .expect("root inline frame") + .text + .push(if checked { '☒' } else { '☐' }), + Event::Rule | Event::Start(_) | Event::End(_) => {} } - - let ch = rest.chars().next().expect("offset is a character boundary"); - result.push(ch); - offset += ch.len_utf8(); } - result + while frames.len() > 1 { + close_inline_frame(&mut frames, &colors); + } + frames.pop().expect("root inline frame").text } } -#[derive(Debug, Clone, Copy)] -enum InlineStyle { +#[derive(Debug)] +enum InlineKind { + Root, Bold, Italic, Strike, + Link(String), + Image(String), } -/// Return an emphasis delimiter only when it can begin a formatting span. -/// This intentionally leaves identifiers such as `snake_case` untouched. -fn emphasis_delimiter(rest: &str) -> Option<(&'static str, InlineStyle)> { - if rest.starts_with("**") { - Some(("**", InlineStyle::Bold)) - } else if rest.starts_with("__") { - Some(("__", InlineStyle::Bold)) - } else if rest.starts_with("~~") { - Some(("~~", InlineStyle::Strike)) - } else if rest.starts_with('*') { - Some(("*", InlineStyle::Italic)) - } else if rest.starts_with('_') { - let next = rest[1..].chars().next(); - if next.is_some_and(|ch| !ch.is_whitespace()) { - Some(("_", InlineStyle::Italic)) - } else { - None +#[derive(Debug)] +struct InlineFrame { + kind: InlineKind, + text: String, +} + +impl InlineFrame { + fn root() -> Self { + Self::new(InlineKind::Root) + } + + fn new(kind: InlineKind) -> Self { + Self { + kind, + text: String::new(), } - } else { - None } } -/// Return the byte offset of the close paren matching the opening paren that -/// follows a Markdown link label. Parentheses in URLs are common in docs. -fn matching_paren(text: &str) -> Option { - let mut depth = 0usize; - let mut escaped = false; - for (offset, ch) in text.char_indices() { - if escaped { - escaped = false; - continue; - } - if ch == '\\' { - escaped = true; - } else if ch == '(' { - depth += 1; - } else if ch == ')' { - if depth == 0 { - return Some(offset); +fn close_inline_frame(frames: &mut Vec, colors: &crate::theme::ThemeColors) { + let Some(frame) = (frames.len() > 1).then(|| frames.pop()).flatten() else { + return; + }; + let rendered = match frame.kind { + InlineKind::Root => frame.text, + InlineKind::Bold => bold(&frame.text), + InlineKind::Italic => italic(&frame.text), + InlineKind::Strike => format!("\x1b[9m{}\x1b[29m", frame.text), + InlineKind::Link(url) => { + let label = colors.md_link.fg(&underline(&frame.text)); + if crate::ansi::strip_ansi(&frame.text) == url { + label + } else { + format!("{label}{}", colors.md_link_url.fg(&format!(" ({url})"))) } - depth -= 1; } - } - None + InlineKind::Image(url) => { + let alt = if frame.text.is_empty() { + "image".to_string() + } else { + frame.text + }; + format!("{alt}{}", colors.md_link_url.fg(&format!(" ({url})"))) + } + }; + frames + .last_mut() + .expect("root inline frame") + .text + .push_str(&rendered); } enum ListMarker { @@ -805,9 +814,35 @@ mod tests { #[test] fn test_markdown_headers() { - let md = Markdown::new("# Title\n## Subtitle\n### Heading", 0, 0); + let md = Markdown::new("# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6", 0, 0); let lines = md.render(80); - assert_eq!(lines.len(), 3); + let plain: Vec = lines + .iter() + .map(|line| crate::ansi::strip_ansi(line)) + .collect(); + assert_eq!(plain, vec!["H1", "H2", "H3", "H4", "H5", "H6"]); + } + + #[test] + fn test_commonmark_inline_parser_handles_nested_spans_and_images() { + let md = Markdown::new( + "This is ***nested***, ``a ` b``, , ![plot](plot.png)", + 0, + 0, + ); + let rendered = md.render(120).join("\n"); + let plain = crate::ansi::strip_ansi(&rendered); + + assert_eq!( + plain, + "This is nested, a ` b , https://example.com, plot (plot.png)" + ); + assert!(rendered.contains("\x1b[1m"), "nested strong style missing"); + assert!(rendered.contains("\x1b[3m"), "nested emphasis missing"); + assert!( + rendered.contains("\x1b[48;2;48;52;59m"), + "multi-backtick code span should use the code chip" + ); } #[test] diff --git a/crates/pi-tui/src/tui_alt_screen.rs b/crates/pi-tui/src/tui_alt_screen.rs index d3273cf..de8cd61 100644 --- a/crates/pi-tui/src/tui_alt_screen.rs +++ b/crates/pi-tui/src/tui_alt_screen.rs @@ -424,6 +424,15 @@ impl TuiAltScreen { let mut visible = frame.lines; self.paint_overlays(&mut visible, width, height); + // Cursor markers are an internal layout protocol, not terminal output. + // Extract the location first, then strip every marker before diffing or + // writing. Sending the APC marker to some Windows terminals caused the + // character under the cursor to be erased when moving left/right. + let cursor = extract_cursor_position(&visible, height); + for line in &mut visible { + *line = line.replace(CURSOR_MARKER, ""); + } + // Check for full redraw let previous = self .previous_screen @@ -454,8 +463,9 @@ impl TuiAltScreen { buffer.push_str(&format!("\x1b[{};1H\x1b[2K{}", row + 1, line)); } - // Find cursor position if present - if let Some((row, col)) = extract_cursor_position(&visible, height) { + // Position the hardware cursor using the marker location captured + // before internal markers were stripped from `visible`. + if let Some((row, col)) = cursor { buffer.push_str(&format!("\x1b[{};{}H", row + 1, col + 1)); if self.get_show_hardware_cursor() { buffer.push_str("\x1b[?25h"); @@ -885,7 +895,8 @@ impl Terminal for TerminalProxy { #[cfg(test)] mod tests { use super::*; - use crate::Text; + use crate::{Editor, Focusable, Text}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; struct RecordingTerminal { output: Arc>, @@ -927,6 +938,28 @@ mod tests { fn flush(&self) {} } + #[test] + fn cursor_marker_is_never_written_to_the_terminal() { + let output = Arc::new(Mutex::new(String::new())); + let terminal = RecordingTerminal { + output: output.clone(), + }; + let tui = TuiAltScreen::new(Box::new(terminal), true, None); + let editor = Arc::new(Editor::simple()); + editor.set_focused(true); + editor.insert("hello"); + tui.set_layout_root(Some(editor.clone())); + tui.start_readerless(); + + editor.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + tui.request_render(false); + + let rendered = output.lock().unwrap().clone(); + assert!(!rendered.contains(CURSOR_MARKER)); + assert!(rendered.contains("hello")); + assert_eq!(editor.get_text(), "hello"); + } + #[test] fn layout_does_not_touch_main_screen_and_stop_clears_it() { let output = Arc::new(Mutex::new(String::new())); diff --git a/deploy/caddy-rpi.override.yml b/deploy/caddy-rpi.override.yml new file mode 100644 index 0000000..4837769 --- /dev/null +++ b/deploy/caddy-rpi.override.yml @@ -0,0 +1,10 @@ +services: + caddy: + volumes: + - ./config/caddy:/etc/caddy:ro + - /var/lib/docker/volumes/app-caddy-data/_data:/data + - /var/lib/docker/volumes/app-caddy-config/_data:/config + - /var/www/openmatrix:/var/www/openmatrix:ro + - /var/www/matrixcode:/var/www/matrixcode:ro + - /var/www/easytier-web:/var/www/easytier-web:ro + - /var/www/rpi:/var/www/rpi:ro diff --git a/deploy/rpi.caddy b/deploy/rpi.caddy new file mode 100644 index 0000000..403e8ca --- /dev/null +++ b/deploy/rpi.caddy @@ -0,0 +1,13 @@ +# rpi.laofu.online - rpi Rust Agent Toolkit static website +rpi.laofu.online { + encode gzip + root * /var/www/rpi + file_server + tls { + issuer acme + } +} + +http://rpi.laofu.online { + redir https://{host}{uri} permanent +} diff --git a/docs/promotion-kit.md b/docs/promotion-kit.md new file mode 100644 index 0000000..9461220 --- /dev/null +++ b/docs/promotion-kit.md @@ -0,0 +1,177 @@ +# rpi 投放素材 + +本文件用于发布 rpi 的开源推广内容。发布前请根据平台规则调整措辞,不要在多个社区原样重复发帖。 + +## 统一信息 + +- 项目:rpi,基于 Pi agent 的 Rust 原生 Agent SDK 和终端 coding agent +- 版本:0.1.6 +- GitHub:https://github.com/bigfish1913/pi-rust +- 官网:https://rpi.laofu.online/ +- 文档:https://rpi.laofu.online/docs.html +- 安装:`cargo install rpi-cli` +- 启动:`rpi -p "hello"` +- 许可证:MIT + +## 中文短文案 + +### V2EX / Linux.do / Rust 中文社区 + +标题: + +> rpi:基于 Pi agent 的 Rust 原生 Agent SDK,支持工具调用、会话和插件 + +正文: + +> rpi 是一个基于 Pi agent 的 Rust 原生实现,采用 library-first、多 crate 的设计,目标是让开发者可以把 Agent runtime 嵌入自己的应用或工作流。 +> +> 目前包含: +> - 异步、流式的 Agent loop +> - Anthropic Messages、OpenAI-compatible 和 faux provider +> - `read`、`write`、`edit`、`bash`、`grep`、`find`、`ls` 等 coding-agent 工具 +> - session、JSONL 持久化、上下文压缩和 prompt templates +> - `rpi-plugin-sdk` 与稳定 ABI 扩展能力 +> - `rpi-cli` 终端编码 Agent +> +> 直接安装: +> +> ```bash +> cargo install rpi-cli +> rpi -p "hello" +> ``` +> +> 项目地址:https://github.com/bigfish1913/pi-rust +> +> 官网和文档:https://rpi.laofu.online/ +> +> 欢迎 Rust、LLM Agent 和开发者工具方向的朋友试用、反馈和贡献。 + +## Hacker News + +标题: + +> Show HN: rpi – A Rust-native Pi agent SDK and terminal coding agent + +正文: + +> rpi is a Rust-native implementation of the Pi agent SDK. It is library-first and split into composable crates for providers, agent runtime, tools, sessions, harnesses, plugins, and a terminal coding-agent CLI. +> +> Highlights: +> - Async, streaming agent runtime +> - Anthropic Messages, OpenAI-compatible chat completions, and a faux provider +> - Built-in coding tools: read, write, edit, bash, grep, find, and ls +> - Durable JSONL sessions, context compaction, hooks, queues, and prompt templates +> - Plugin SDK and stable ABI registration for tools, providers, events, and resources +> +> ```bash +> cargo install rpi-cli +> rpi -p "hello" +> ``` +> +> GitHub: https://github.com/bigfish1913/pi-rust +> +> Website: https://rpi.laofu.online/ +> +> The project is MIT licensed and currently at version 0.1.6. + +## Reddit + +### r/rust + +标题: + +> [Showcase] rpi: a Rust-native Pi agent SDK with composable crates and plugin ABI + +重点:Rust crate 分层、异步流式 runtime、trait 设计、插件 ABI、测试和 faux provider。避免只强调“又一个 AI CLI”。 + +### r/LocalLLaMA / r/LLMDevs + +标题: + +> rpi: a Rust coding-agent toolkit with provider adapters, built-in tools, sessions, and plugins + +重点:Provider 适配、工具循环、会话持久化、OpenAI-compatible endpoint 和不需要 API key 的本地示例。发布前查看各版自荐规则。 + +## GitHub Release + +建议 Release 标题: + +> rpi v0.1.6 — Rust-native Pi agent SDK and CLI + +Release 摘要: + +> rpi v0.1.6 provides the first complete path from provider and agent loop to built-in coding tools, sessions, harness, plugins, and the `rpi` terminal CLI. +> +> Install the CLI with: +> +> ```bash +> cargo install rpi-cli +> ``` +> +> See the documentation at https://rpi.laofu.online/docs.html and the architecture guide at https://github.com/bigfish1913/pi-rust/blob/main/docs/architecture.md. + +## This Week in Rust + +Issue title: + +> Project Submission: rpi — Rust-native Pi agent SDK and terminal coding agent + +Issue body: + +> Hi TWiR team, +> +> I would like to propose rpi for the Project/Crate of the Week. +> +> rpi is a Rust-native implementation of the Pi agent SDK and a terminal coding-agent CLI. It follows a library-first design and splits providers, the async agent loop, coding tools, durable sessions, harnesses, plugins, and the CLI into composable crates. +> +> Highlights: +> - Async, streaming Agent runtime with tool calls, hooks, queues, and cancellation. +> - Anthropic Messages, OpenAI-compatible Chat Completions, and a deterministic faux provider. +> - Built-in `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls` tools. +> - JSONL session persistence, branching, prompt templates, and context compaction. +> - A stable `#[repr(C)]` plugin ABI through `rpi-plugin-sdk`, with a host-side dynamic loader. +> - A terminal CLI that can be installed with `cargo install rpi-cli`. +> +> Quick start: +> +> ```bash +> cargo install rpi-cli +> rpi -p "hello" +> ``` +> +> Links: +> - Repository: https://github.com/bigfish1913/pi-rust +> - Website: https://rpi.laofu.online/ +> - Crates.io: https://crates.io/crates/rpi-cli +> - Docs.rs: https://docs.rs/rpi-cli +> - Plugin SDK: https://crates.io/crates/rpi-plugin-sdk + +## Awesome Rust PR + +Suggested list entry for an appropriate AI / agent section: + +> - [rpi](https://github.com/bigfish1913/pi-rust) - Rust-native Pi agent SDK and terminal coding-agent CLI with composable providers, tools, sessions, and a stable plugin ABI. + +Use the list maintainer's preferred category and contribution format. Submit the +entry as a focused PR rather than opening multiple issues. + +## X / Bluesky / LinkedIn + +> Introducing rpi: a Rust-native implementation of the Pi agent SDK. +> +> Build composable coding agents with async streaming, provider adapters, built-in tools, durable sessions, and a plugin ABI. +> +> Install: `cargo install rpi-cli` +> +> GitHub: https://github.com/bigfish1913/pi-rust +> Website: https://rpi.laofu.online/ + +## 发布检查清单 + +- 确认链接使用 `https://github.com/bigfish1913/pi-rust`,不要使用旧仓库地址。 +- 确认版本号为 `0.1.6`,不要写成 `0.1.x`。 +- 确认安装命令是 `cargo install rpi-cli`。 +- 每个平台使用一张最相关的截图,正文中说明截图展示的是 CLI 启动或工作状态。 +- Hacker News 和 Reddit 使用英文;中文社区使用中文,并按版规选择分类。 +- 发帖后优先回复安装、Provider 配置和插件扩展问题,不要连续重复推送。 +- GitHub Issue、论坛帖子和社交平台公开提交前,确认标题、链接、截图和项目状态无误。 diff --git a/docs/release-v0.1.6.md b/docs/release-v0.1.6.md new file mode 100644 index 0000000..68f3a19 --- /dev/null +++ b/docs/release-v0.1.6.md @@ -0,0 +1,26 @@ +# rpi v0.1.6 + +rpi v0.1.6 is a Rust-native implementation of the Pi agent SDK, with a library-first multi-crate workspace and a terminal coding-agent CLI. + +## Highlights + +- Async, streaming agent runtime +- Anthropic Messages, OpenAI-compatible Chat Completions, and faux provider +- Built-in `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls` coding tools +- JSONL sessions, context compaction, hooks, queues, and prompt templates +- `rpi-plugin-sdk` and stable ABI extension points + +## Install + +```bash +cargo install rpi-cli +rpi -p "hello" +``` + +## Links + +- Documentation: https://rpi.laofu.online/docs.html +- Architecture: https://github.com/bigfish1913/pi-rust/blob/main/docs/architecture.md +- Website: https://rpi.laofu.online/ + +MIT License. diff --git a/examples/minimal/Cargo.toml b/examples/minimal/Cargo.toml index 59e5534..181e820 100644 --- a/examples/minimal/Cargo.toml +++ b/examples/minimal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "minimal" -version = "0.1.5" +version = "0.1.6" edition = "2021" publish = false diff --git a/examples/plugin-stub/Cargo.toml b/examples/plugin-stub/Cargo.toml index 0022612..6e2a304 100644 --- a/examples/plugin-stub/Cargo.toml +++ b/examples/plugin-stub/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "plugin-stub" -version = "0.1.5" +version = "0.1.6" edition = "2021" publish = false description = "Minimal rpi Rust-cdylib plugin: registers an `echo` tool + one event handler (the Part B2 ABI smoke target)." diff --git a/examples/tools/Cargo.toml b/examples/tools/Cargo.toml index bdaab51..b5653ea 100644 --- a/examples/tools/Cargo.toml +++ b/examples/tools/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tools-example" -version = "0.1.5" +version = "0.1.6" edition = "2021" publish = false diff --git a/examples/tui/Cargo.toml b/examples/tui/Cargo.toml index 39da4d6..c0ad5e1 100644 --- a/examples/tui/Cargo.toml +++ b/examples/tui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tui-example" -version = "0.1.5" +version = "0.1.6" edition = "2021" publish = false diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..0a1d9a5 --- /dev/null +++ b/website/README.md @@ -0,0 +1,11 @@ +# rpi 官网 + +这是一个零依赖的静态官网原型。首页数据来自 `data/site.json`,Packages 目录数据来自 `data/packages.json`,Documentation 数据来自 `data/docs.json`;页面逻辑分别在 `app.js`、`packages.js` 和 `docs.js`,共享 `styles.css`。 + +在仓库根目录启动: + +```powershell +python -m http.server 4173 --directory website +``` + +然后打开 。文档页支持“使用文档 / SDK 文档”切换和章节搜索。后续把页面里的 `fetch(...)` 替换为 API 请求即可接入真实后台。 diff --git a/website/app.js b/website/app.js new file mode 100644 index 0000000..d0c896b --- /dev/null +++ b/website/app.js @@ -0,0 +1,85 @@ +const state = { data: null, activeExample: 0 }; +const $ = (selector, scope = document) => scope.querySelector(selector); +const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)]; + +function getPath(source, path) { return path.split('.').reduce((value, key) => value?.[key], source); } + +function renderFeatures(features) { + $('[data-features]').innerHTML = features.map((feature) => ` +
${feature.number}${feature.tag}

${feature.title}

${feature.description}

+ `).join(''); +} + +function renderArchitecture(layers) { + $('[data-architecture]').innerHTML = layers.map((layer, index) => ` +
0${index + 1}
${layer.name}
${layer.label}

${layer.description}

${index < layers.length - 1 ? '' : ''}
+ `).join(''); +} + +function renderExamples(examples) { + const tabs = $('[data-example-tabs]'); + tabs.innerHTML = examples.map((example, index) => ` + + `).join(''); + $$('.code-tab', tabs).forEach((tab) => tab.addEventListener('click', () => { state.activeExample = Number(tab.dataset.exampleIndex); updateExample(examples); })); + updateExample(examples); +} + +function updateExample(examples) { + const example = examples[state.activeExample]; + $$('.code-tab').forEach((tab, index) => { const active = index === state.activeExample; tab.classList.toggle('is-active', active); tab.setAttribute('aria-selected', String(active)); }); + $('[data-code-file]').textContent = example.file; + $('[data-code-note]').textContent = example.note; + $('[data-code-content]').textContent = example.code; +} + +function renderReleases(releases) { + $('[data-releases]').innerHTML = releases.map((release) => ` +
${release.version}${release.status}

${release.title}

${release.description}

+ `).join(''); +} + +function bindCopy(buttonSelector, getText, successMessage) { + $(buttonSelector)?.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(getText()); showToast(successMessage); } catch { showToast('复制失败,请手动选择文本'); } + }); +} + +function showToast(message) { + const toast = $('[data-toast]'); toast.textContent = message; toast.classList.add('is-visible'); + window.clearTimeout(showToast.timer); showToast.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2200); +} + +function bindMenu() { + const toggle = $('[data-menu-toggle]'); const menu = $('[data-mobile-menu]'); + toggle.addEventListener('click', () => { const open = menu.classList.toggle('is-open'); toggle.setAttribute('aria-expanded', String(open)); }); + $$('a', menu).forEach((link) => link.addEventListener('click', () => { menu.classList.remove('is-open'); toggle.setAttribute('aria-expanded', 'false'); })); +} + +function bindScrollEffects() { + const header = $('[data-sticky-header]'); const marker = document.createElement('div'); marker.className = 'scroll-marker'; document.body.prepend(marker); + const observer = new IntersectionObserver(([entry]) => header.classList.toggle('is-scrolled', !entry.isIntersecting), { threshold: 0 }); observer.observe(marker); +} + +async function init() { + const response = await fetch('./data/site.json'); state.data = await response.json(); + $$('[data-content]').forEach((element) => { + const value = getPath(state.data, element.dataset.content); + if (!value) return; + if (element.dataset.content === 'hero.title') { + const [lead] = value.split('你自己的工具。'); + element.innerHTML = `${lead}
你自己的工具。`; + return; + } + element.textContent = value; + }); + $$('[data-stat]').forEach((element) => { const value = getPath(state.data, element.dataset.stat); if (value) element.textContent = value; }); + renderFeatures(state.data.features); renderArchitecture(state.data.architecture); renderExamples(state.data.examples); renderReleases(state.data.releases); + bindCopy('[data-copy-code]', () => state.data.examples[state.activeExample].code, '代码已复制'); + $$('[data-copy-command]').forEach((button) => button.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(button.previousElementSibling.textContent); showToast('命令已复制'); } catch { showToast('复制失败,请手动选择文本'); } + })); + bindMenu(); bindScrollEffects(); +} + +init().catch((error) => { console.error(error); showToast('数据加载失败,请通过本地开发服务器打开网站'); }); diff --git a/website/data/docs.json b/website/data/docs.json new file mode 100644 index 0000000..b713f01 --- /dev/null +++ b/website/data/docs.json @@ -0,0 +1,188 @@ +{ + "title": "Documentation", + "intro": "使用文档和 SDK 文档,帮助你从第一个 faux agent 开始,逐步接入工具、会话和插件。", + "modes": { + "guide": { + "label": "使用文档", + "eyebrow": "RPI / USER GUIDE", + "description": "面向第一次使用 rpi 的开发者:从安装、运行到把 Agent 接进自己的应用。", + "sections": [ + { + "id": "quickstart", + "kicker": "01 / FIRST RUN", + "title": "快速开始", + "summary": "先用 faux provider 跑通完整闭环,不需要 API key,也不需要网络。", + "body": [ + "rpi 是 library-first 的 Rust Agent toolkit。建议先运行仓库里的 minimal 示例,再根据需要组合 rpi-ai、rpi-agent 和 rpi-tools。", + "faux provider 会返回确定性的本地响应,适合学习事件流、写测试和验证工具循环。" + ], + "code": {"language": "shell", "file": "examples/minimal/src/main.rs", "content": "cargo run -p minimal"}, + "links": [{"label": "README / How you build an agent", "url": "https://github.com/bigfish1913/pi-rust#how-you-build-an-agent"}] + }, + { + "id": "install", + "kicker": "02 / INSTALL", + "title": "安装与运行", + "summary": "Rust 1.78+、Cargo 和 Tokio 是开始使用的最低条件;也可以直接安装 rpi-cli。", + "body": [ + "只想直接使用 CLI 时执行 cargo install rpi-cli;想研究 Agent loop,再克隆仓库运行 minimal 示例。Cargo workspace 会按依赖顺序编译所有 crate。", + "真实 Provider 的凭据可以通过命令行参数、~/.rpi/auth.json、models.json 或环境变量提供。不要把密钥提交进仓库。" + ], + "code": {"language": "shell", "file": "README.md", "content": "# 直接安装 CLI\ncargo install rpi-cli\nrpi -p \"hello\"\n\n# 从源码运行示例\ngit clone https://github.com/bigfish1913/pi-rust.git\ncd pi-rust\ncargo run -p minimal"}, + "links": [{"label": "配置说明", "url": "https://github.com/bigfish1913/pi-rust#configuration"}] + }, + { + "id": "first-agent", + "kicker": "03 / AGENT", + "title": "第一个 Agent", + "summary": "用 AgentBuilder 声明模型、系统提示词和工具,然后订阅事件并发送 prompt。", + "body": [ + "AgentBuilder 把运行时依赖集中在构建阶段,Agent 本身负责维护消息、事件和待处理 prompt。事件订阅应在调用 prompt 前建立。", + "生产环境通常会把事件映射到 UI 或 telemetry;测试环境可以直接收集 AgentEnd 事件做断言。" + ], + "code": {"language": "rust", "file": "README.md", "content": "use rpi_agent::{Agent, AgentEvent};\nuse rpi_ai::providers::faux::faux_provider;\n\nlet model = faux_provider().model(\"echo\");\nlet mut agent = Agent::builder(model)\n .system_prompt(\"You are a helpful assistant.\")\n .build();\n\nlet mut events = agent.subscribe();\nagent.prompt(\"Hello!\").await?;\nwhile let Some(event) = events.recv().await {\n if matches!(event, AgentEvent::AgentEnd { .. }) { break; }\n}"}, + "links": [{"label": "rpi-agent API", "url": "https://github.com/bigfish1913/pi-rust/tree/main/crates/pi-agent"}] + }, + { + "id": "tools", + "kicker": "04 / TOOLS", + "title": "接入工具", + "summary": "把 AgentTool 加入 builder;内置工具通过 ExecutionEnv 隔离文件系统和命令执行。", + "body": [ + "rpi-tools 提供 read、write、edit、bash、grep、find、ls 七个基础工具。读操作可以直接执行,写操作通过 MutationQueue 串行化。", + "在测试中使用 InMemoryExecutionEnv 可以避免触碰真实文件系统;需要自定义工具时,实现 rpi-agent 的 AgentTool trait。" + ], + "code": {"language": "rust", "file": "crates/pi-tools/src/lib.rs", "content": "use rpi_tools::{create_read_tool, InMemoryExecutionEnv};\n\nlet env = InMemoryExecutionEnv::default();\nlet read = create_read_tool(env.clone());\nlet mut agent = Agent::builder(model)\n .tool(read)\n .build();"}, + "links": [{"label": "内置工具与 ExecutionEnv", "url": "https://github.com/bigfish1913/pi-rust/tree/main/crates/pi-tools"}] + }, + { + "id": "providers", + "kicker": "05 / PROVIDERS", + "title": "配置 Provider", + "summary": "统一的 Model / Provider 抽象让 Anthropic、OpenAI-compatible 和 faux provider 可以互换。", + "body": [ + "默认模型会从已认证的模型目录中选择。自定义网关写入 ~/.rpi/agent/models.json,并通过 provider/model 的形式传给 CLI。", + "Anthropic 支持 ANTHROPIC_API_KEY 或 ANTHROPIC_AUTH_TOKEN;OpenAI-compatible endpoint 使用 openai-completions API 配置。" + ], + "code": {"language": "json", "file": "~/.rpi/agent/models.json", "content": "{\n \"providers\": {\n \"gateway\": {\n \"api\": \"anthropic-messages\",\n \"baseUrl\": \"https://gateway.example.com\",\n \"apiKey\": \"sk-gateway-secret\",\n \"models\": [{ \"id\": \"custom-claude\", \"name\": \"Custom Claude\" }]\n }\n }\n}"}, + "links": [{"label": "Provider 配置", "url": "https://github.com/bigfish1913/pi-rust#configuration"}] + }, + { + "id": "sessions", + "kicker": "06 / PERSISTENCE", + "title": "Session 和 Harness", + "summary": "需要持久化、压缩和上下文文件时,把 Agent 交给 AgentHarness。", + "body": [ + "rpi-harness 在 Agent 之上组合 session tree、JSONL 持久化、compaction、skills、prompt templates 和 context files。核心 Agent 仍然可以独立使用。", + "建议把 session id 作为产品层的稳定标识,使用 JSONL backend 记录可回放的事件和消息。" + ], + "code": {"language": "rust", "file": "crates/pi-harness/src/lib.rs", "content": "use rpi_harness::AgentHarness;\n\nlet harness = AgentHarness::builder(agent)\n .session_dir(\".rpi/sessions\")\n .build()\n .await?;\nharness.prompt(\"Summarize this file\").await?;"}, + "links": [{"label": "Harness 模块", "url": "https://github.com/bigfish1913/pi-rust/tree/main/crates/pi-harness"}] + }, + { + "id": "extensions", + "kicker": "07 / EXTENSIONS", + "title": "插件与扩展", + "summary": "用稳定 ABI 的 cdylib 插件注册工具、Provider、命令和资源。", + "body": [ + "插件 crate 依赖 rpi-plugin-sdk,通过 rpi_plugin_register 导出入口接收 PluginApiVt,并向宿主注册工具、Provider、事件和资源处理器。宿主的 rpi-extensions 负责发现、加载、注册和生命周期管理。", + "把跨边界的数据限制在 SDK 定义的可序列化类型内;插件不应直接依赖宿主内部模块。" + ], + "code": {"language": "rust", "file": "examples/plugin-stub/src/lib.rs", "content": "use rpi_plugin_sdk::{register_entrypoint, PluginApiVt};\n\n#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(\n api: *const PluginApiVt,\n abi_version: u32,\n) -> i32 {\n register_entrypoint(api, abi_version, |api| {\n // api.register_tool / register_provider / register_event_handler\n // are the stable ABI registration slots.\n let _ = api;\n 0\n })\n}"}, + "links": [{"label": "插件 stub", "url": "https://github.com/bigfish1913/pi-rust/tree/main/examples/plugin-stub"}, {"label": "插件加载器", "url": "https://github.com/bigfish1913/pi-rust/tree/main/crates/rpi-extensions"}] + }, + { + "id": "testing", + "kicker": "08 / TESTING", + "title": "测试与 faux provider", + "summary": "让模型和执行环境都可替换,测试 Agent 行为而不是测试网络。", + "body": [ + "faux provider 适合验证消息顺序、工具调用和 AgentEnd;InMemoryExecutionEnv 适合验证读写工具。两者组合可以构造完全离线的集成测试。", + "对 Provider 适配层再补充少量 HTTP fixture 测试,覆盖流式事件解析、错误映射和工具参数校验。" + ], + "code": {"language": "rust", "file": "crates/pi-ai/src/lib.rs", "content": "let model = faux_provider().model(\"echo\");\nlet mut agent = Agent::builder(model)\n .tool(create_read_tool(InMemoryExecutionEnv::default()))\n .build();\nagent.prompt(\"read README.md\").await?;"}, + "links": [{"label": "faux provider 源码", "url": "https://github.com/bigfish1913/pi-rust/tree/main/crates/pi-ai"}] + } + ] + }, + "sdk": { + "label": "SDK 文档", + "eyebrow": "RPI / SDK REFERENCE", + "description": "按 crate 和核心 trait 查找 API 边界,适合实现自定义 Provider、工具、Harness 或插件。", + "sections": [ + { + "id": "crate-overview", + "kicker": "01 / WORKSPACE", + "title": "Crate 总览", + "summary": "六层 workspace 保持单向依赖:从 telemetry 和 AI 类型一路组合到 CLI。", + "body": ["发布名使用 rpi- 前缀,磁盘目录仍保留 pi-* 历史路径。核心 crate 可以独立嵌入,不必依赖 CLI。", "典型依赖方向是 rpi-telemetry → rpi-ai → rpi-agent → rpi-tools → rpi-harness → rpi-cli。"], + "code": {"language": "text", "file": "README.md", "content": "rpi-telemetry telemetry contracts\nrpi-ai provider types + streaming\nrpi-agent runtime loop + AgentTool\nrpi-tools built-in tools + ExecutionEnv\nrpi-harness sessions + compaction\nrpi-cli terminal application"}, + "links": [{"label": "Workspace README", "url": "https://github.com/bigfish1913/pi-rust#crates-published-as-rpi-"}] + }, + { + "id": "rpi-ai", + "kicker": "02 / RPI-AI", + "title": "rpi-ai", + "summary": "统一 Model、Provider、Message、Context 和流式 AssistantMessageEvent。", + "body": ["Provider 负责把统一请求转换成具体模型 API,并将响应转换回 SDK 事件。validate_tool_arguments 在调用前校验 JSON 参数。", "实现新 Provider 时优先复用现有消息类型和流式事件,不要在上层泄漏供应商专用结构。"], + "code": {"language": "rust", "file": "crates/pi-ai/src/provider.rs", "content": "#[async_trait]\npub trait Provider: Send + Sync {\n fn id(&self) -> &str;\n fn models(&self) -> &[Model];\n async fn stream_simple(\n &self,\n model: &Model,\n ctx: &Context,\n opts: &SimpleStreamOptions,\n ) -> AssistantMessageEventStream;\n}"}, + "links": [{"label": "rpi-ai exports", "url": "https://github.com/bigfish1913/pi-rust/blob/main/crates/pi-ai/src/lib.rs"}] + }, + { + "id": "rpi-agent", + "kicker": "03 / RPI-AGENT", + "title": "rpi-agent", + "summary": "Agent、AgentBuilder、AgentTool、AgentEvent 和 run_agent_loop 构成运行时核心。", + "body": ["AgentBuilder 负责组装模型、系统提示词、工具和 hooks;run_agent_loop 负责在消息、工具调用和 Provider 流之间推进状态。", "PendingMessageQueue 允许在运行过程中排队新的用户消息,订阅 AgentEvent 可以驱动界面和 telemetry。"], + "code": {"language": "rust", "file": "crates/pi-agent/src/agent_tool.rs", "content": "#[async_trait]\npub trait AgentTool: Send + Sync {\n fn schema(&self) -> &Tool;\n fn label(&self) -> &str;\n async fn execute(\n &self,\n tool_call_id: &str,\n params: serde_json::Value,\n signal: CancellationToken,\n on_update: Arc,\n ) -> Result;\n}"}, + "links": [{"label": "rpi-agent exports", "url": "https://github.com/bigfish1913/pi-rust/blob/main/crates/pi-agent/src/lib.rs"}] + }, + { + "id": "rpi-tools", + "kicker": "04 / RPI-TOOLS", + "title": "rpi-tools", + "summary": "ExecutionEnv 把工具的文件、进程和路径操作隔离出来。", + "body": ["OsExecutionEnv 连接真实操作系统,InMemoryExecutionEnv 为测试提供内存实现。create_*_tool 函数返回可直接注册的 AgentTool。", "将高风险写操作放在 MutationQueue 中串行执行,并在产品层增加确认策略。"], + "code": {"language": "rust", "file": "crates/pi-tools/src/lib.rs", "content": "pub trait ExecutionEnv: Send + Sync {\n async fn read_file(&self, path: &Path) -> Result;\n async fn write_file(&self, path: &Path, content: &str) -> Result<()>;\n async fn run_command(&self, command: &str) -> Result;\n}\n\npub fn create_grep_tool(env: Arc) -> impl AgentTool { /* ... */ }"}, + "links": [{"label": "ExecutionEnv API", "url": "https://github.com/bigfish1913/pi-rust/blob/main/crates/pi-tools/src/lib.rs"}] + }, + { + "id": "rpi-harness", + "kicker": "05 / RPI-HARNESS", + "title": "rpi-harness", + "summary": "在 Agent 之上提供 session、compaction、skills、模板和上下文文件。", + "body": ["Harness 是产品集成层:它知道如何恢复 session、裁剪上下文、加载 skills,并把系统提示词拼成一次运行所需的 Context。", "如果你只需要一次性的 Agent 调用,可以直接使用 rpi-agent;需要持久化时再引入 Harness。"], + "code": {"language": "rust", "file": "crates/pi-harness/src/lib.rs", "content": "pub mod agent_harness;\npub mod session;\npub mod compaction;\npub mod skills;\npub mod prompt_templates;\npub mod context_files;\npub mod events;\npub mod system_prompt;"}, + "links": [{"label": "Harness modules", "url": "https://github.com/bigfish1913/pi-rust/tree/main/crates/pi-harness"}] + }, + { + "id": "lifecycle", + "kicker": "06 / LIFECYCLE", + "title": "Agent 生命周期", + "summary": "构建、订阅、prompt、工具循环、结束事件是最小生命周期。", + "body": ["创建 Agent 后先订阅事件,再发送 prompt。Provider 产生消息增量,工具调用完成后会回到循环,最终以 AgentEnd 收束。", "hooks 用于在生命周期节点插入日志、策略或 UI 状态同步,但不应改变核心消息契约。"], + "code": {"language": "text", "file": "crates/pi-agent/src/lib.rs", "content": "AgentBuilder\n -> Agent::build\n -> subscribe()\n -> prompt(message)\n -> Provider stream\n -> AgentTool execution\n -> AgentEvent::AgentEnd"}, + "links": [{"label": "Agent loop", "url": "https://github.com/bigfish1913/pi-rust/blob/main/crates/pi-agent/src/lib.rs"}] + }, + { + "id": "events", + "kicker": "07 / EVENTS", + "title": "Provider 与 AgentEvent", + "summary": "用统一事件流连接模型、运行时和前端,不把供应商协议带到 UI。", + "body": ["AssistantMessageEventStream 表达文本增量、工具调用和结束原因;AgentEvent 在此基础上加入工具执行和运行生命周期。", "UI 应按事件增量渲染,持久化层则在结束时保存完整消息和 usage。"], + "code": {"language": "rust", "file": "crates/pi-ai/src/lib.rs", "content": "pub enum AssistantMessageEvent {\n Start,\n TextDelta(String),\n ToolCall { name: String, arguments: Value },\n Done { stop_reason: StopReason },\n Error(String),\n}"}, + "links": [{"label": "AI event types", "url": "https://github.com/bigfish1913/pi-rust/blob/main/crates/pi-ai/src/lib.rs"}] + }, + { + "id": "plugin-abi", + "kicker": "08 / PLUGIN ABI", + "title": "插件 ABI", + "summary": "rpi-plugin-sdk 是插件和宿主之间唯一应依赖的边界。", + "body": ["插件以 cdylib 编译,由 rpi-extensions loader 发现并加载。registry 负责保存 API 注册的工具、Provider、事件和资源处理器,宿主控制卸载与错误隔离。", "ABI 变更应提升版本并同时更新 plugin-stub 与 smoke test;不要让插件直接链接 rpi-cli 私有模块。"], + "code": {"language": "toml", "file": "crates/rpi-plugin-sdk/Cargo.toml", "content": "[lib]\ncrate-type = [\"cdylib\"]\n\n[dependencies]\nrpi-plugin-sdk = \"0.1\""}, + "links": [{"label": "SDK crate", "url": "https://github.com/bigfish1913/pi-rust/tree/main/crates/rpi-plugin-sdk"}, {"label": "ABI smoke test", "url": "https://github.com/bigfish1913/pi-rust/tree/main/crates/rpi-extensions/tests"}] + } + ] + } + } +} diff --git a/website/data/packages.json b/website/data/packages.json new file mode 100644 index 0000000..0179fe5 --- /dev/null +++ b/website/data/packages.json @@ -0,0 +1,74 @@ +[ + { + "name": "rpi-cli", + "type": "crate", + "age": 1, + "ageLabel": "v0.1.6 · current", + "description": "终端 coding-agent CLI,提供 rpi 命令、交互式 TUI、配置和插件加载。", + "author": "rpi / crates.io", + "downloads": 105, + "downloadsLabel": "105", + "install": "cargo install rpi-cli", + "repository": "https://crates.io/crates/rpi-cli" + }, + { + "name": "rpi-agent", + "type": "crate", + "age": 2, + "ageLabel": "v0.1.6 · current", + "description": "异步 Agent runtime 和 loop,包含 AgentTool、事件、hooks 与消息队列。", + "author": "rpi / crates.io", + "downloads": 165, + "downloadsLabel": "165", + "install": "cargo add rpi-agent", + "repository": "https://crates.io/crates/rpi-agent" + }, + { + "name": "rpi-tools", + "type": "crate", + "age": 3, + "ageLabel": "v0.1.6 · current", + "description": "开箱即用的 read、write、edit、bash、grep、find、ls 工具和 ExecutionEnv。", + "author": "rpi / crates.io", + "downloads": 138, + "downloadsLabel": "138", + "install": "cargo add rpi-tools", + "repository": "https://crates.io/crates/rpi-tools" + }, + { + "name": "rpi-plugin-sdk", + "type": "extension", + "age": 4, + "ageLabel": "v0.1.6 · ABI v1", + "description": "Rust cdylib 插件的稳定 #[repr(C)] ABI 合约,插件只依赖这一层边界。", + "author": "rpi / crates.io", + "downloads": 37, + "downloadsLabel": "37", + "install": "cargo add rpi-plugin-sdk", + "repository": "https://crates.io/crates/rpi-plugin-sdk" + }, + { + "name": "rpi-extensions", + "type": "extension", + "age": 5, + "ageLabel": "v0.1.6 · loader", + "description": "宿主侧动态插件加载器,将 ABI 插件适配为 AgentTool 并管理生命周期。", + "author": "rpi / crates.io", + "downloads": 38, + "downloadsLabel": "38", + "install": "cargo add rpi-extensions", + "repository": "https://crates.io/crates/rpi-extensions" + }, + { + "name": "rpi-harness", + "type": "crate", + "age": 6, + "ageLabel": "v0.1.6 · sessions", + "description": "Session 树、JSONL 持久化、上下文压缩和 AgentHarness run loop。", + "author": "rpi / crates.io", + "downloads": 126, + "downloadsLabel": "126", + "install": "cargo add rpi-harness", + "repository": "https://crates.io/crates/rpi-harness" + } +] diff --git a/website/data/site.json b/website/data/site.json new file mode 100644 index 0000000..abe8d48 --- /dev/null +++ b/website/data/site.json @@ -0,0 +1,47 @@ +{ + "hero": { + "eyebrow": "RUST AGENT TOOLKIT", + "title": "把 Agent 做成你自己的工具。", + "description": "rpi 是一个 library-first 的 Rust SDK。用清晰的分层、可替换的模型 Provider 和真正可测试的工具循环,构建属于你的 coding agent。" + }, + "stats": [ + { "value": "6", "label": "个可组合 crate" }, + { "value": "2", "label": "种内置 Provider" }, + { "value": "7", "label": "个开箱即用工具" }, + { "value": "0", "label": "网络依赖的测试" } + ], + "features": [ + { "number": "01", "title": "Library-first", "description": "核心能力不依赖 CLI。只引入你需要的 crate,从一个 Agent loop 开始。", "tag": "可组合" }, + { "number": "02", "title": "Provider agnostic", "description": "Anthropic、OpenAI-compatible 或你自己的服务,都落在同一个 Provider 边界后面。", "tag": "可替换" }, + { "number": "03", "title": "Tool loop ready", "description": "工具参数校验、并行执行、流式事件、取消和 follow-up 都是循环的一部分。", "tag": "可观测" }, + { "number": "04", "title": "Durable by choice", "description": "需要时再接入 Session、JSONL、分支和上下文压缩,不为简单实验提前付复杂度。", "tag": "可持久化" } + ], + "architecture": [ + { "name": "rpi-ai", "label": "Models + streaming", "description": "统一消息类型、Provider 和流式事件", "tone": "orange" }, + { "name": "rpi-agent", "label": "Agent loop", "description": "工具调用、事件、Hook、steering", "tone": "mint" }, + { "name": "rpi-tools", "label": "Built-in tools", "description": "read / write / edit / bash / grep", "tone": "blue" }, + { "name": "rpi-harness", "label": "Sessions", "description": "会话树、JSONL、compaction", "tone": "violet" }, + { "name": "rpi-cli", "label": "Your interface", "description": "CLI、TUI、配置和插件", "tone": "cream" } + ], + "examples": [ + { + "id": "minimal", + "label": "Minimal agent", + "file": "examples/minimal/src/main.rs", + "note": "确定性的本地 provider,不需要 API key。", + "code": "use rpi_agent::AgentBuilder;\nuse rpi_ai::providers::faux::{FauxProvider, FauxScript};\n\n#[tokio::main]\nasync fn main() {\n let script = FauxScript::new()\n .with_text(\"Hello from rpi!\");\n let provider = FauxProvider::new(script);\n let model = provider.default_model().clone();\n\n let agent = AgentBuilder::new()\n .model(model)\n .stream_fn(make_stream_fn(provider))\n .build()\n .expect(\"agent builds\");\n\n agent.prompt(\"hi\").await.expect(\"prompt completes\");\n}\n" + }, + { + "id": "tools", + "label": "Tool loop", + "file": "examples/tools/src/main.rs", + "note": "把内置 write 和 bash 工具接进真实文件系统。", + "code": "let env = Arc::new(OsExecutionEnv::with_cwd(tmp));\nlet ctx = ExecutionToolContext::new(\n env.clone() as Arc,\n Some(env as Arc),\n);\n\nlet write = create_write_tool(&ctx);\nlet bash = create_bash_tool(&ctx, None);\n\nlet agent = AgentBuilder::new()\n .model(model)\n .stream_fn(make_stream_fn(provider))\n .tools(vec![write, bash])\n .build()?;\n\nagent.prompt(\"Write hello to out.txt\").await?;\n" + } + ], + "releases": [ + { "version": "0.1.6", "status": "current", "title": "Core stable", "description": "Provider、Agent loop、工具、Harness 和 CLI 组成第一条完整路径。" }, + { "version": "next", "status": "building", "title": "More surfaces", "description": "扩展 package manifest、更多 Provider 和更完整的插件资源协议。" }, + { "version": "future", "status": "exploring", "title": "Your ideas", "description": "把 rpi 接进你的产品、工作流和自己的运行环境。" } + ] +} diff --git a/website/docs.html b/website/docs.html new file mode 100644 index 0000000..ad3f7de --- /dev/null +++ b/website/docs.html @@ -0,0 +1,44 @@ + + + + + + + Documentation · rpi + + + + + + +
+
+ rpi + + +
+ + +
+
+
RPI / KNOWLEDGE BASE
+

Documentation

+

使用文档和 SDK 文档,帮助你从第一个 faux agent 开始,逐步接入工具、会话和插件。

+
+
+
+
+ +
+
+ +

+
+
+
+ +
+
+ + + diff --git a/website/docs.js b/website/docs.js new file mode 100644 index 0000000..bac7dd3 --- /dev/null +++ b/website/docs.js @@ -0,0 +1,64 @@ +const $ = (selector, scope = document) => scope.querySelector(selector); +const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)]; +const state = { data: null, mode: 'guide', query: '' }; + +function showToast(message) { + const toast = $('[data-toast]'); toast.textContent = message; toast.classList.add('is-visible'); + clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('is-visible'), 2200); +} + +function bindMenu() { + const toggle = $('[data-menu-toggle]'); const menu = $('[data-mobile-menu]'); + toggle.addEventListener('click', () => { const open = menu.classList.toggle('is-open'); toggle.setAttribute('aria-expanded', String(open)); }); + $$('a', menu).forEach((link) => link.addEventListener('click', () => { menu.classList.remove('is-open'); toggle.setAttribute('aria-expanded', 'false'); })); +} + +function bindScrollEffects() { + const header = $('[data-sticky-header]'); const marker = document.createElement('div'); marker.className = 'scroll-marker'; document.body.prepend(marker); + new IntersectionObserver(([entry]) => header.classList.toggle('is-scrolled', !entry.isIntersecting), { threshold: 0 }).observe(marker); +} + +function currentMode() { return state.data.modes[state.mode]; } + +function matches(section) { + const query = state.query.trim().toLowerCase(); + if (!query) return true; + return `${section.title} ${section.summary} ${section.kicker} ${section.body.join(' ')} ${section.code?.file || ''}`.toLowerCase().includes(query); +} + +function renderModes() { + $('[data-docs-modes]').innerHTML = Object.entries(state.data.modes).map(([key, mode], index) => ``).join(''); + $$('[data-docs-mode]').forEach((button) => button.addEventListener('click', () => { state.mode = button.dataset.docsMode; state.query = ''; $('[data-docs-search]').value = ''; render(); })); +} + +function renderNav(sections) { + const nav = $('[data-docs-nav]'); + nav.innerHTML = sections.length ? sections.map((section) => `${section.kicker.split(' / ')[0]}${section.title}`).join('') : ''; + $$('a', nav).forEach((link) => link.addEventListener('click', (event) => { event.preventDefault(); document.getElementById(link.hash.slice(1))?.scrollIntoView({ behavior: 'smooth', block: 'start' }); history.replaceState(null, '', link.hash); })); +} + +function sectionMarkup(section) { + const links = (section.links || []).map((link) => `${link.label} ↗`).join(''); + const code = section.code ? `
${section.code.file}
${section.code.content}
` : ''; + return `
${section.kicker}

${section.title}

${section.summary}

${section.body.map((paragraph) => `

${paragraph}

`).join('')}${code}
`; +} + +function bindCodeCopy() { + $$('[data-copy-docs-code]').forEach((button) => button.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(button.closest('.docs-code').querySelector('code').textContent); showToast('代码已复制'); } catch { showToast('复制失败,请手动选择文本'); } + })); +} + +function render() { + const mode = currentMode(); const sections = mode.sections.filter(matches); + $('[data-docs-eyebrow]').textContent = mode.eyebrow; $('[data-docs-description]').textContent = mode.description; + renderModes(); renderNav(sections); $('[data-docs-sections]').innerHTML = sections.map(sectionMarkup).join(''); $('[data-docs-empty]').hidden = sections.length > 0; bindCodeCopy(); +} + +async function init() { + const response = await fetch('./data/docs.json?v=2'); state.data = await response.json(); + bindMenu(); bindScrollEffects(); render(); + $('[data-docs-search]').addEventListener('input', (event) => { state.query = event.target.value; render(); }); +} + +init().catch((error) => { console.error(error); showToast('文档数据加载失败,请通过本地开发服务器打开网站'); }); diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..e8e992e --- /dev/null +++ b/website/index.html @@ -0,0 +1,62 @@ + + + + + + + rpi — Rust Agent Toolkit + + + + + + +
+
+ rpi + + +
+ + +
+
+
+
RUST AGENT TOOLKIT
+

把 Agent 做成
你自己的工具。

+

rpi 是一个 library-first 的 Rust SDK。用清晰的分层、可替换的模型 Provider 和真正可测试的工具循环,构建属于你的 coding agent。

+ +
MIT LicenseRust 1.78+无网络也能测试
+
+
+
+
+
rpi / agent-session LIVE
+
+
$ cargo run -p minimal
+
01Agent::prompt("hi")
+
02stream.start · faux-1
+
03↳ assistant_message
04Hello from the faux provider!
+
05message.end · 18ms
+
AgentEnd observed. 2 messages after run
+
+ +
+
STREAMING18ms
COMPOSABLE6 crates
+
+
+
6个可组合 crate
2种内置 Provider
7个开箱即用工具
0网络依赖的测试
+ +
01 / WHY RPI

从一条 prompt,
长出完整系统。

Agent 不应该被锁在一个 CLI 里。rpi 把模型、循环、工具、会话和终端体验拆成可以独立替换的 Rust 层,让你从最小实验开始,逐步长成自己的产品。

+
02 / ARCHITECTURE

每一层都有自己的边界。

从 Provider 到 CLI,单向依赖让核心库保持轻盈,也让你的扩展有明确的落点。

+
03 / START SMALL

先跑起来,
再加复杂度。

两个示例覆盖最重要的学习路径:先用 faux provider 跑通 Agent,再把真实文件系统和工具接进循环。

examples/minimal/src/main.rs
确定性的本地 provider,不需要 API key。浏览 examples ↗
+
04 / EXTEND THE LOOP

把你的能力
插进 Agent。

Rust cdylib 插件通过稳定 ABI 接入 Tool、Provider、Slash Command、事件和资源发现。宿主保留生命周期,插件只关注自己的能力。

了解插件系统
+
05 / RELEASES

小步发布,持续靠近。

核心能力优先稳定下来,再把更多 Provider、插件生态和 CLI 体验交给社区。

+
06 / GET STARTED

你的 Agent,
从这里开始。

想直接使用 CLI 就安装 rpi-cli;想研究 Agent loop,再拉源码运行 faux provider。

直接安装 CLI
$cargo install rpi-cli
从源码运行示例
$git clone https://github.com/bigfish1913/pi-rust.git && cd pi-rust && cargo run -p minimal
+
+ +
+
+ + + diff --git a/website/packages.html b/website/packages.html new file mode 100644 index 0000000..ae1cdff --- /dev/null +++ b/website/packages.html @@ -0,0 +1,44 @@ + + + + + + + Packages · rpi + + + + + + +
+
+ rpi + + +
+ + +
+
+
RPI / PACKAGE CATALOG
+

把 Agent 的能力,
装进你的工作流。

+

扩展、skills、providers 和 prompt templates 的静态目录。先从这里发现组合方式,再用 Cargo 或 Git 接入自己的 rpi 项目。

+
$cargo add rpi-tools
+
+ +
+
DISCOVER / 0 PACKAGES

最近值得试试的东西。

这些条目先用静态 JSON 代替后端数据。以后可以直接接 crates.io、GitHub 或你自己的 registry。

+
+
+
0 个结果
+
+ +
+
+ +
+
+ + + diff --git a/website/packages.js b/website/packages.js new file mode 100644 index 0000000..ce22b02 --- /dev/null +++ b/website/packages.js @@ -0,0 +1,62 @@ +const $ = (selector, scope = document) => scope.querySelector(selector); +const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)]; +const state = { packages: [], query: '', type: 'all', sort: 'downloads' }; + +function showToast(message) { + const toast = $('[data-toast]'); toast.textContent = message; toast.classList.add('is-visible'); + clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('is-visible'), 2200); +} + +function bindMenu() { + const toggle = $('[data-menu-toggle]'); const menu = $('[data-mobile-menu]'); + toggle.addEventListener('click', () => { const open = menu.classList.toggle('is-open'); toggle.setAttribute('aria-expanded', String(open)); }); + $$('a', menu).forEach((link) => link.addEventListener('click', () => { menu.classList.remove('is-open'); toggle.setAttribute('aria-expanded', 'false'); })); +} + +function bindScrollEffects() { + const header = $('[data-sticky-header]'); const marker = document.createElement('div'); marker.className = 'scroll-marker'; document.body.prepend(marker); + new IntersectionObserver(([entry]) => header.classList.toggle('is-scrolled', !entry.isIntersecting), { threshold: 0 }).observe(marker); +} + +function sortedPackages() { + const query = state.query.trim().toLowerCase(); + return state.packages.filter((item) => { + const haystack = `${item.name} ${item.description} ${item.author}`.toLowerCase(); + return (state.type === 'all' || item.type === state.type) && (!query || haystack.includes(query)); + }).sort((a, b) => { + if (state.sort === 'name') return a.name.localeCompare(b.name); + if (state.sort === 'recent') return a.age - b.age; + return b.downloads - a.downloads; + }); +} + +function packageCard(item) { + return `
${item.type}${item.ageLabel}

${item.name}

${item.description}

${item.author}${item.downloadsLabel}/mo
${item.install}
`; +} + +function renderRecent() { + $('[data-recent-packages]').innerHTML = state.packages.slice(0, 4).map((item) => `${item.name}${item.description}${item.ageLabel}`).join(''); +} + +function renderList() { + const items = sortedPackages(); + $('[data-package-count]').textContent = state.packages.length; + $('[data-results-count]').textContent = `${items.length} 个结果`; + $('[data-package-list]').innerHTML = items.map(packageCard).join(''); + $('[data-empty-state]').hidden = items.length > 0; + $$('[data-copy-package]').forEach((button) => button.addEventListener('click', async () => { + try { await navigator.clipboard.writeText(button.dataset.copyPackage); showToast('安装命令已复制'); } catch { showToast('复制失败,请手动选择文本'); } + })); +} + +async function init() { + const response = await fetch('./data/packages.json'); state.packages = await response.json(); + renderRecent(); renderList(); bindMenu(); bindScrollEffects(); + $('[data-package-search]').addEventListener('input', (event) => { state.query = event.target.value; renderList(); }); + $('[data-package-type]').addEventListener('change', (event) => { state.type = event.target.value; renderList(); }); + $('[data-package-sort]').addEventListener('change', (event) => { state.sort = event.target.value; renderList(); }); + $('[data-reset]').addEventListener('click', () => { state.query = ''; state.type = 'all'; state.sort = 'downloads'; $('[data-package-search]').value = ''; $('[data-package-type]').value = 'all'; $('[data-package-sort]').value = 'downloads'; renderList(); }); + $('[data-copy-command]').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('[data-copy-command]').previousElementSibling.textContent); showToast('安装命令已复制'); } catch { showToast('复制失败,请手动选择文本'); } }); +} + +init().catch((error) => { console.error(error); showToast('数据加载失败,请通过本地开发服务器打开网站'); }); diff --git a/website/styles.css b/website/styles.css new file mode 100644 index 0000000..7697efc --- /dev/null +++ b/website/styles.css @@ -0,0 +1,53 @@ +:root { + --ink: #101113; --paper: #f4f1e9; --paper-deep: #e9e5da; --muted: #6f706d; --line: rgba(16, 17, 19, .14); + --orange: #ec6b38; --orange-deep: #a84422; --white: #fffdf8; + --mono: 'DM Mono', 'SFMono-Regular', Consolas, monospace; --sans: 'Manrope', 'PingFang SC', 'Microsoft YaHei', sans-serif; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); -webkit-font-smoothing: antialiased; } +a { color: inherit; text-decoration: none; } button { font: inherit; } .site-shell { overflow: hidden; } +.scroll-marker { position: absolute; top: 0; left: 0; width: 1px; height: 1px; } +.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 22px clamp(22px, 5vw, 76px); background: rgba(244,241,233,.94); transition: box-shadow .3s, padding .3s; } +.topbar.is-scrolled { box-shadow: 0 8px 25px rgba(16,17,19,.08); padding-top: 15px; padding-bottom: 15px; backdrop-filter: blur(12px); } +.brand, .footer-brand { display: inline-flex; align-items: center; gap: 10px; font-size: 23px; font-weight: 800; letter-spacing: -.06em; } +.brand-mark { display: inline-flex; align-items: flex-end; gap: 3px; height: 22px; }.brand-mark span { width: 5px; border-radius: 1px; background: var(--ink); display: block; }.brand-mark span:nth-child(1) { height: 13px; opacity: .55; }.brand-mark span:nth-child(2) { height: 19px; background: var(--orange); }.brand-mark span:nth-child(3) { height: 9px; opacity: .45; } +.desktop-nav { display: flex; gap: clamp(18px, 3vw, 40px); margin-left: 10vw; font-size: 12px; font-weight: 700; }.desktop-nav a, .text-link { transition: color .2s; }.desktop-nav a:hover, .text-link:hover { color: var(--orange-deep); } +.topbar-actions { display: flex; align-items: center; gap: 25px; }.text-link { font-size: 12px; font-weight: 700; }.button { display: inline-flex; align-items: center; justify-content: center; gap: 17px; min-height: 50px; padding: 0 22px; background: var(--ink); color: var(--white); border: 1px solid var(--ink); font-size: 12px; font-weight: 700; transition: transform .2s, background .2s, color .2s; }.button:hover { transform: translateY(-2px); background: var(--orange); border-color: var(--orange); }.button-small { min-height: 38px; padding: 0 16px; gap: 12px; font-size: 11px; }.button-ghost { color: var(--ink); background: transparent; }.button-ghost:hover { color: var(--white); } +.menu-toggle { display: none; border: 0; background: transparent; padding: 7px 0 7px 12px; cursor: pointer; }.menu-toggle span { display: block; width: 22px; height: 1px; background: var(--ink); margin: 5px 0; }.mobile-menu { display: none; } +.page-section { width: min(1320px, calc(100% - 44px)); margin: 0 auto; } +.hero { min-height: 680px; display: grid; grid-template-columns: minmax(0, .95fr) minmax(500px, 1.05fr); align-items: center; gap: clamp(30px, 5vw, 78px); padding-top: clamp(60px, 8vw, 105px); padding-bottom: clamp(70px, 10vw, 135px); } +.eyebrow { display: flex; align-items: center; gap: 10px; color: var(--orange); font-family: var(--mono); font-size: 10px; font-weight: 500; letter-spacing: .13em; }.eyebrow-dark { color: var(--muted); }.status-dot { width: 6px; height: 6px; background: var(--orange); border-radius: 50%; box-shadow: 0 0 0 5px rgba(236,107,56,.14); } +.hero h1 { max-width: 700px; margin: 28px 0 24px; font-size: clamp(52px, 6vw, 84px); line-height: .98; letter-spacing: -.08em; font-weight: 800; } .hero-title-line { display: inline-block; white-space: nowrap; } h1 em, h2 span { color: var(--orange); font-style: normal; }.hero-lede { max-width: 500px; margin: 0; color: var(--muted); font-size: 16px; line-height: 1.85; }.hero-actions { display: flex; gap: 11px; margin-top: 34px; }.hero-meta { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 34px; color: var(--muted); font-size: 10px; font-family: var(--mono); }.meta-dot { display: inline-block; width: 6px; height: 6px; margin-right: 7px; border-radius: 50%; }.green { background: #6eb195; }.orange { background: var(--orange); }.blue { background: #668eaf; } +.hero-visual { position: relative; padding: 30px 0 30px 30px; }.visual-glow { position: absolute; width: 75%; height: 75%; top: 12%; left: 14%; background: var(--orange); filter: blur(90px); opacity: .12; }.terminal-window { position: relative; overflow: hidden; background: #171819; color: #d9ddd3; box-shadow: 20px 28px 50px rgba(16,17,19,.18); }.terminal-bar { height: 48px; display: flex; align-items: center; padding: 0 17px; border-bottom: 1px solid rgba(255,255,255,.1); font-family: var(--mono); font-size: 10px; }.window-dots { display: flex; gap: 6px; margin-right: auto; }.window-dots i { width: 7px; height: 7px; display: block; border-radius: 50%; background: #555; }.window-dots i:first-child { background: #dd6c46; }.window-dots i:nth-child(2) { background: #cfa85c; }.window-dots i:nth-child(3) { background: #6ea690; }.terminal-title { position: absolute; left: 50%; transform: translateX(-50%); color: #858983; }.terminal-live { margin-left: auto; color: #7eb59d; font-size: 9px; }.terminal-live span { display: inline-block; width: 5px; height: 5px; margin-right: 5px; border-radius: 50%; background: #7eb59d; box-shadow: 0 0 0 3px rgba(126,181,157,.16); } +.terminal-body { min-height: 355px; padding: 28px 25px; font-family: var(--mono); font-size: 12px; line-height: 2.1; }.terminal-line { white-space: pre-wrap; }.terminal-muted, .syntax-dim { color: #6f756f; }.terminal-spacer { height: 14px; }.prompt-mark { color: var(--orange); margin-right: 10px; }.line-index { display: inline-block; width: 26px; color: #464b48; user-select: none; }.syntax-key { color: #b8d3c3; }.syntax-punct { color: #777e78; }.syntax-fn { color: #eca06f; }.syntax-text { color: #dedfce; }.syntax-string { color: #d9c886; }.syntax-comment { color: #7d9689; }.event-dot { display: inline-block; width: 5px; height: 5px; margin: 0 8px 2px 1px; border-radius: 50%; }.orange-fill { background: var(--orange); }.blue-fill { background: #86aece; }.terminal-success { color: #9ec7ae; }.terminal-cursor { display: inline-block; width: 7px; height: 14px; margin-left: 27px; background: var(--orange); vertical-align: -2px; animation: blink 1.1s steps(2) infinite; }.terminal-footer { display: flex; justify-content: space-between; padding: 11px 18px; color: #686e69; border-top: 1px solid rgba(255,255,255,.08); font: 9px var(--mono); text-transform: uppercase; letter-spacing: .08em; } @keyframes blink { 50% { opacity: 0; } } +.floating-stat { position: absolute; z-index: 2; display: flex; flex-direction: column; background: var(--white); box-shadow: 8px 12px 22px rgba(16,17,19,.1); padding: 13px 17px; min-width: 112px; }.stat-top { top: 3%; right: -8px; }.stat-bottom { bottom: 3%; left: -4px; }.stat-label { color: var(--muted); font: 8px var(--mono); letter-spacing: .1em; }.floating-stat strong { margin-top: 4px; font-size: 19px; letter-spacing: -.05em; }.floating-stat strong span { color: var(--orange); font: 9px var(--mono); letter-spacing: 0; } +.signal-strip { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); width: min(1320px, calc(100% - 44px)); margin: 0 auto; }.signal-item { padding: 25px 20px; border-right: 1px solid var(--line); }.signal-item:first-child { padding-left: 0; }.signal-item:last-child { border-right: 0; }.signal-item strong { display: block; font-size: 30px; line-height: 1; letter-spacing: -.08em; }.signal-item span { display: block; margin-top: 8px; color: var(--muted); font: 10px var(--mono); } +.intro { padding-top: clamp(100px, 13vw, 175px); padding-bottom: clamp(100px, 12vw, 160px); }.section-heading h2 { margin: 22px 0 0; font-size: clamp(37px, 5.2vw, 69px); line-height: 1.02; letter-spacing: -.08em; }.split-heading { display: grid; grid-template-columns: 1fr minmax(290px, .7fr); gap: 70px; align-items: end; }.split-heading > p { max-width: 450px; margin: 0 0 4px; color: var(--muted); font-size: 14px; line-height: 1.9; }.feature-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-top: 70px; }.feature-card { min-height: 265px; position: relative; display: flex; flex-direction: column; padding: 22px; background: var(--white); border: 1px solid rgba(16,17,19,.06); }.feature-top { display: flex; align-items: center; justify-content: space-between; }.feature-number, .feature-tag { font: 10px var(--mono); }.feature-number { color: var(--orange); }.feature-tag { color: var(--muted); }.feature-card h3 { margin: auto 0 12px; font-size: 20px; letter-spacing: -.05em; }.feature-card p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }.feature-line { width: 32px; height: 2px; margin-top: 26px; background: var(--orange); } +.architecture-section { padding-bottom: clamp(100px, 13vw, 175px); }.centered-heading { text-align: center; }.centered-heading .eyebrow { justify-content: center; }.centered-heading > p { max-width: 500px; margin: 20px auto 0; color: var(--muted); font-size: 14px; line-height: 1.8; }.architecture-map { position: relative; max-width: 760px; margin: 60px auto 0; }.architecture-layer { position: relative; display: grid; grid-template-columns: 70px 165px 1fr; gap: 20px; align-items: center; min-height: 83px; padding: 15px 22px; background: var(--white); border-left: 4px solid var(--orange); }.architecture-layer + .architecture-layer { margin-top: 11px; }.layer-index { color: var(--muted); font: 10px var(--mono); }.layer-name { font: 15px var(--mono); font-weight: 500; }.layer-label { font-size: 13px; font-weight: 700; }.architecture-layer p { grid-column: 3; margin: 3px 0 0; color: var(--muted); font-size: 11px; }.layer-arrow { position: absolute; bottom: -18px; left: 50%; z-index: 2; display: grid; place-items: center; width: 24px; height: 24px; background: var(--paper); color: var(--orange); font: 15px var(--mono); transform: translateX(-50%); }.tone-mint { border-left-color: #75ad98; }.tone-blue { border-left-color: #7399ba; }.tone-violet { border-left-color: #9185bb; }.tone-cream { border-left-color: #b18e4a; } +.examples-section { padding-bottom: clamp(105px, 13vw, 175px); }.examples-heading { align-items: start; }.code-lab { display: grid; grid-template-columns: 220px 1fr; gap: 0; margin-top: 62px; background: #1c1e1e; box-shadow: 15px 20px 40px rgba(16,17,19,.12); }.code-tabs { display: flex; flex-direction: column; padding: 15px 0; border-right: 1px solid rgba(255,255,255,.1); }.code-tab { display: flex; align-items: center; gap: 10px; width: 100%; padding: 17px 19px; border: 0; border-left: 2px solid transparent; color: #777d78; background: transparent; text-align: left; cursor: pointer; font: 10px var(--mono); transition: background .2s, color .2s; }.code-tab:hover { color: #d3d7ca; }.code-tab.is-active { border-left-color: var(--orange); color: #f0f0e9; background: rgba(255,255,255,.06); }.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }.code-panel { min-width: 0; }.code-panel-top { display: flex; justify-content: space-between; align-items: center; height: 50px; padding: 0 20px; border-bottom: 1px solid rgba(255,255,255,.1); }.code-file { color: #888f88; font: 10px var(--mono); }.copy-button { padding: 4px 0; border: 0; color: #aeb5ab; background: transparent; cursor: pointer; font: 10px var(--mono); }.copy-button:hover { color: var(--orange); }.code-panel pre { min-height: 315px; overflow: auto; margin: 0; padding: 26px 30px; color: #d2d7ca; font: 12px/2 var(--mono); tab-size: 4; }.code-panel-foot { display: flex; justify-content: space-between; gap: 15px; padding: 13px 20px; border-top: 1px solid rgba(255,255,255,.1); color: #6e766e; font: 9px var(--mono); }.code-panel-foot a { color: #d8a37b; } +.extension-section { padding-bottom: clamp(100px, 13vw, 165px); }.extension-card { display: grid; grid-template-columns: .86fr 1.14fr; overflow: hidden; min-height: 390px; background: var(--ink); color: var(--white); }.extension-copy { padding: clamp(35px, 6vw, 75px); }.extension-copy h2 { margin: 22px 0 18px; font-size: clamp(38px, 5vw, 65px); line-height: .99; letter-spacing: -.08em; }.extension-copy p { max-width: 410px; margin: 0; color: #9b9d98; font-size: 13px; line-height: 1.9; }.light-link { display: inline-block; margin-top: 28px; color: #e7ab85; }.extension-terminal { position: relative; display: flex; flex-direction: column; justify-content: center; gap: 21px; padding: 45px clamp(25px, 7vw, 100px); background: #202322; }.extension-row { display: grid; grid-template-columns: minmax(150px, 1fr) 35px minmax(110px, .65fr); gap: 10px; align-items: center; font: 12px var(--mono); }.extension-key { color: #a8cdbb; }.extension-arrow { color: var(--orange); }.extension-value { color: #e0c986; }.extension-corner { position: absolute; right: 22px; bottom: 19px; color: #6d756e; font: 9px var(--mono); letter-spacing: .12em; }.extension-corner span { color: #83b49e; margin-left: 8px; } +.release-section { padding-bottom: clamp(100px, 13vw, 170px); }.release-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 60px; }.release-card { position: relative; min-height: 205px; padding: 23px; border-top: 2px solid var(--ink); background: var(--white); }.release-current { border-top-color: var(--orange); }.release-building { border-top-color: #75ad98; }.release-exploring { border-top-color: #8c9bb8; }.release-top { display: flex; justify-content: space-between; font: 10px var(--mono); }.release-version { color: var(--orange); }.release-status { color: var(--muted); text-transform: uppercase; }.release-card h3 { margin: 30px 0 10px; font-size: 19px; letter-spacing: -.05em; }.release-card p { max-width: 290px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }.release-mark { position: absolute; right: 22px; bottom: 19px; color: var(--orange); font-size: 20px; } +.get-started-section { width: 100%; padding: clamp(75px, 9vw, 115px) 22px; background: var(--paper-deep); }.get-started-inner { width: min(770px, 100%); margin: 0 auto; text-align: center; }.get-started-inner h2 { margin: 20px 0 17px; font-size: clamp(40px, 5.7vw, 73px); line-height: .99; letter-spacing: -.08em; }.get-started-inner > p { max-width: 520px; margin: 0 auto; color: var(--muted); font-size: 14px; line-height: 1.8; }.command-box { display: flex; align-items: center; margin-top: 34px; padding: 0 13px 0 18px; min-height: 53px; background: var(--ink); color: #d7dcd0; text-align: left; }.command-prompt { margin-right: 10px; color: var(--orange); font: 14px var(--mono); }.command-box code { overflow: auto; flex: 1; white-space: nowrap; font: 10px var(--mono); }.command-box button { padding: 9px; border: 0; color: #aaaFA8; background: transparent; cursor: pointer; }.command-box button:hover { color: var(--orange); }.get-started-links { display: flex; justify-content: center; gap: 10px; margin-top: 25px; } +.command-stack { margin-top: 30px; text-align: left; }.command-label { margin: 18px 0 8px; color: var(--muted); font: 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; }.command-stack .command-box { margin-top: 0; } +.footer { display: flex; align-items: center; justify-content: space-between; padding: 27px clamp(22px, 5vw, 76px); background: var(--ink); color: var(--white); }.footer .brand-mark span:nth-child(1), .footer .brand-mark span:nth-child(3) { background: #7d827d; }.footer-tagline { margin-left: 8px; color: #777b75; font: 10px var(--mono); letter-spacing: .05em; }.footer-links { display: flex; align-items: center; gap: 28px; color: #969b92; font: 10px var(--mono); }.footer-links a:hover { color: var(--orange); }.toast { position: fixed; z-index: 30; right: 22px; bottom: 22px; padding: 12px 16px; color: var(--white); background: var(--ink); box-shadow: 0 8px 25px rgba(16,17,19,.2); font: 10px var(--mono); opacity: 0; transform: translateY(10px); pointer-events: none; transition: opacity .2s, transform .2s; }.toast.is-visible { opacity: 1; transform: translateY(0); } +@media (max-width: 980px) { .desktop-nav { margin-left: 0; }.hero { grid-template-columns: 1fr; padding-top: 75px; }.hero-copy { max-width: 720px; }.hero-visual { width: min(720px, 100%); margin: 0 auto; padding-left: 20px; }.feature-grid { grid-template-columns: repeat(2, 1fr); }.extension-card { grid-template-columns: 1fr; }.extension-terminal { min-height: 280px; }.split-heading { gap: 35px; }.topbar-actions { gap: 15px; } } +@media (max-width: 680px) { + .desktop-nav, .desktop-only { display: none; }.menu-toggle { display: block; }.mobile-menu { display: flex; position: fixed; z-index: 19; top: 69px; left: 0; right: 0; flex-direction: column; padding: 13px 22px 24px; background: rgba(244,241,233,.98); border-bottom: 1px solid var(--line); transform: translateY(-120%); transition: transform .25s; }.mobile-menu.is-open { transform: translateY(0); }.mobile-menu a { padding: 15px 0; border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 700; }.topbar { padding: 18px 22px; }.page-section, .signal-strip { width: min(100% - 36px, 1320px); }.hero { min-height: 0; padding-top: 66px; padding-bottom: 80px; }.hero h1 { font-size: clamp(47px, 14vw, 73px); }.hero-lede { font-size: 14px; }.hero-actions, .get-started-links { flex-wrap: wrap; }.hero-meta { gap: 11px; font-size: 9px; }.hero-visual { padding: 20px 0 28px; }.terminal-body { min-height: 320px; padding: 20px 15px; font-size: 10px; }.terminal-title { display: none; }.floating-stat { padding: 10px 12px; min-width: 91px; }.stat-top { right: -5px; }.stat-bottom { left: -5px; }.signal-strip { grid-template-columns: repeat(2, 1fr); }.signal-item, .signal-item:first-child { padding: 18px 12px; }.signal-item:nth-child(2) { border-right: 0; }.signal-item:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.signal-item strong { font-size: 25px; }.signal-item span { font-size: 9px; }.split-heading { grid-template-columns: 1fr; gap: 25px; }.section-heading h2 { font-size: clamp(40px, 12vw, 58px); }.feature-grid { grid-template-columns: 1fr; margin-top: 43px; }.feature-card { min-height: 205px; }.architecture-map { margin-top: 42px; }.architecture-layer { grid-template-columns: 42px 1fr; gap: 8px 15px; min-height: 92px; padding: 15px; }.layer-label { grid-column: 2; }.architecture-layer p { grid-column: 2; margin-top: 0; }.layer-arrow { bottom: -18px; }.code-lab { grid-template-columns: 1fr; margin-top: 42px; }.code-tabs { flex-direction: row; overflow: auto; border-right: 0; border-bottom: 1px solid rgba(255,255,255,.1); }.code-tab { min-width: max-content; border-left: 0; border-bottom: 2px solid transparent; padding: 14px 16px; }.code-tab.is-active { border-left: 0; border-bottom-color: var(--orange); }.code-panel pre { min-height: 280px; padding: 21px 16px; font-size: 10px; }.code-panel-foot { flex-direction: column; gap: 8px; }.extension-copy { padding: 38px 24px; }.extension-terminal { padding: 35px 20px; gap: 17px; }.extension-row { grid-template-columns: 1fr 22px .8fr; font-size: 10px; }.release-grid { grid-template-columns: 1fr; margin-top: 42px; }.release-card { min-height: 180px; }.get-started-section { padding-left: 18px; padding-right: 18px; }.command-box { align-items: flex-start; padding-top: 15px; padding-bottom: 15px; }.command-box code { white-space: normal; line-height: 1.5; }.footer { align-items: flex-start; flex-direction: column; gap: 22px; padding: 28px 22px; }.footer-links { flex-wrap: wrap; gap: 15px 22px; }.footer-tagline { display: none; } +} + +/* Package catalog */ +.desktop-nav .is-current { color: var(--orange-deep); } +.packages-hero { padding-top: clamp(90px, 12vw, 155px); padding-bottom: clamp(75px, 10vw, 120px); }.packages-hero h1 { max-width: 800px; margin: 24px 0 18px; font-size: clamp(50px, 7vw, 90px); line-height: .98; letter-spacing: -.08em; }.packages-hero h1 em { color: var(--orange); font-style: normal; }.packages-hero > p { max-width: 565px; margin: 0; color: var(--muted); font-size: 15px; line-height: 1.85; }.packages-install { display: flex; align-items: center; width: min(520px, 100%); min-height: 53px; margin-top: 32px; padding: 0 13px 0 18px; color: #d7dcd0; background: var(--ink); }.packages-install code { flex: 1; font: 11px var(--mono); }.packages-install button { padding: 8px; border: 0; color: #aab0a7; background: transparent; cursor: pointer; }.packages-install button:hover { color: var(--orange); } +.package-catalog { padding-bottom: clamp(90px, 12vw, 160px); }.catalog-heading { display: grid; grid-template-columns: 1fr minmax(270px, .6fr); gap: 60px; align-items: end; }.catalog-heading h2 { margin: 20px 0 0; font-size: clamp(36px, 4.4vw, 60px); line-height: 1; letter-spacing: -.08em; }.catalog-heading > p { max-width: 420px; margin: 0 0 4px; color: var(--muted); font-size: 13px; line-height: 1.8; }.recent-packages { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 48px; padding: 15px; border: 1px solid var(--line); }.recent-package { min-height: 145px; display: flex; flex-direction: column; padding: 18px; background: rgba(255,253,248,.62); border: 1px solid rgba(16,17,19,.08); transition: transform .2s, border-color .2s; }.recent-package:hover { transform: translateY(-3px); border-color: var(--orange); }.recent-package strong { font: 12px var(--mono); }.recent-package span { margin-top: auto; color: var(--muted); font-size: 11px; line-height: 1.5; }.recent-package small { margin-top: 14px; color: var(--orange-deep); font: 9px var(--mono); letter-spacing: .08em; } +.catalog-toolbar { display: grid; grid-template-columns: 1fr 155px 155px; gap: 10px; margin-top: 64px; }.search-field, .select-field { display: flex; align-items: center; min-height: 48px; border: 1px solid var(--line); background: var(--white); }.search-field > span { padding: 0 13px 0 15px; color: var(--orange); font-size: 21px; line-height: 1; }.search-field input, .select-field select { width: 100%; min-width: 0; height: 46px; border: 0; outline: 0; color: var(--ink); background: transparent; font: 11px var(--mono); }.search-field input::placeholder { color: #9a9b96; }.select-field { padding-left: 13px; gap: 8px; }.select-field span { color: var(--muted); font: 9px var(--mono); }.select-field select { cursor: pointer; }.catalog-meta { display: flex; justify-content: space-between; align-items: center; margin: 18px 0 10px; color: var(--muted); font: 10px var(--mono); }.reset-button { padding: 0; border: 0; color: var(--orange-deep); background: transparent; cursor: pointer; font: inherit; }.reset-button:hover { color: var(--ink); }.package-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }.package-card { min-height: 257px; display: flex; flex-direction: column; padding: 22px; background: var(--white); border: 1px solid rgba(16,17,19,.08); }.package-card-top, .package-card-bottom { display: flex; justify-content: space-between; align-items: center; }.package-type, .package-age { font: 9px var(--mono); letter-spacing: .08em; text-transform: uppercase; }.package-type { color: var(--orange-deep); }.type-provider { color: #557f9e; }.type-skill { color: #588b79; }.type-extension { color: #7a6aa2; }.type-prompt { color: #9b7d3e; }.package-age { color: var(--muted); }.package-card h3 { margin: 26px 0 9px; font: 16px var(--mono); }.package-card h3 a:hover { color: var(--orange-deep); }.package-card h3 a span { color: var(--orange); font-size: 13px; }.package-card > p { max-width: 540px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.65; }.package-card-bottom { margin-top: auto; padding-top: 18px; color: var(--muted); font: 10px var(--mono); }.package-downloads { color: var(--orange-deep); }.package-command { display: flex; align-items: center; gap: 8px; min-height: 34px; margin-top: 14px; padding: 0 9px 0 11px; color: #777e78; background: var(--paper); }.package-command code { overflow: hidden; flex: 1; text-overflow: ellipsis; white-space: nowrap; font: 9px var(--mono); }.package-command button { padding: 5px 0 5px 5px; border: 0; color: var(--orange-deep); background: transparent; cursor: pointer; }.empty-state { padding: 45px; border: 1px dashed var(--line); color: var(--muted); text-align: center; font: 11px var(--mono); } +@media (max-width: 980px) { .recent-packages { grid-template-columns: repeat(2, 1fr); }.catalog-heading { gap: 35px; } } +@media (max-width: 680px) { .packages-hero { padding-top: 70px; }.packages-hero h1 { font-size: clamp(45px, 13vw, 70px); }.catalog-heading { grid-template-columns: 1fr; gap: 25px; }.recent-packages { grid-template-columns: 1fr; margin-top: 38px; }.catalog-toolbar { grid-template-columns: 1fr; margin-top: 45px; }.package-list { grid-template-columns: 1fr; }.package-card { min-height: 240px; } } + +/* Documentation */ +.docs-hero { padding-top: clamp(76px, 10vw, 125px); padding-bottom: clamp(55px, 7vw, 90px); }.docs-hero h1 { margin: 22px 0 17px; font-size: clamp(52px, 8vw, 104px); line-height: .94; letter-spacing: -.085em; }.docs-hero > p { max-width: 610px; margin: 0; color: var(--muted); font-size: 15px; line-height: 1.85; } +.docs-workspace { padding-bottom: clamp(95px, 12vw, 160px); }.docs-toolbar { display: flex; align-items: stretch; justify-content: space-between; gap: 20px; padding: 15px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }.docs-modes { display: flex; gap: 5px; }.docs-mode { display: inline-flex; align-items: center; gap: 11px; min-height: 43px; padding: 0 16px; border: 1px solid transparent; color: var(--muted); background: transparent; cursor: pointer; font: 11px var(--mono); transition: color .2s, background .2s, border-color .2s; }.docs-mode span { color: var(--orange); font-size: 9px; }.docs-mode:hover { color: var(--ink); }.docs-mode.is-active { color: var(--ink); background: var(--white); border-color: var(--line); }.docs-search { display: flex; align-items: center; width: min(315px, 100%); min-height: 43px; border: 1px solid var(--line); background: var(--white); }.docs-search > span { padding: 0 12px 0 14px; color: var(--orange); font-size: 21px; }.docs-search input { width: 100%; height: 41px; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--ink); font: 10px var(--mono); }.docs-search input::placeholder { color: #9a9b96; } +.docs-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: clamp(38px, 7vw, 100px); margin-top: 45px; }.docs-sidebar { position: sticky; top: 100px; align-self: start; }.docs-sidebar-label { margin-bottom: 17px; color: var(--muted); font: 9px var(--mono); letter-spacing: .12em; }.docs-sidebar nav { display: flex; flex-direction: column; border-left: 1px solid var(--line); }.docs-sidebar nav a { display: flex; align-items: baseline; gap: 10px; padding: 9px 13px; color: var(--muted); font: 11px/1.35 var(--mono); border-left: 2px solid transparent; transform: translateX(-1px); transition: color .2s, border-color .2s, background .2s; }.docs-sidebar nav a span { min-width: 17px; color: var(--orange); font-size: 9px; }.docs-sidebar nav a:hover { color: var(--ink); background: rgba(255,253,248,.55); }.docs-repo-link { display: inline-block; margin-top: 28px; color: var(--orange-deep); font: 10px var(--mono); }.docs-repo-link:hover { color: var(--ink); } +.docs-mode-intro { display: grid; grid-template-columns: 1fr minmax(220px, .72fr); gap: 35px; align-items: end; margin-bottom: 60px; padding-bottom: 23px; border-bottom: 2px solid var(--ink); }.docs-mode-intro p { max-width: 355px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.8; }.docs-section { display: grid; grid-template-columns: minmax(190px, .64fr) minmax(0, 1fr); gap: clamp(25px, 5vw, 76px); padding: 0 0 62px; scroll-margin-top: 105px; }.docs-section + .docs-section { padding-top: 63px; border-top: 1px solid var(--line); }.docs-section-heading h2 { margin: 18px 0 12px; font-size: clamp(27px, 3.1vw, 43px); line-height: 1; letter-spacing: -.07em; }.docs-summary { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.75; }.docs-section-body > p { margin: 0 0 15px; color: #464844; font-size: 13px; line-height: 1.85; }.docs-code { margin: 28px 0 19px; overflow: hidden; background: var(--ink); color: #d7dcd0; box-shadow: 10px 14px 25px rgba(16,17,19,.09); }.docs-code-top { display: flex; align-items: center; justify-content: space-between; min-height: 42px; padding: 0 14px 0 17px; border-bottom: 1px solid rgba(255,255,255,.1); color: #858d84; font: 9px var(--mono); }.docs-code-top button { border: 0; color: #aeb5ab; background: transparent; cursor: pointer; font: 9px var(--mono); }.docs-code-top button:hover { color: var(--orange); }.docs-code pre { overflow: auto; margin: 0; padding: 19px 20px 22px; font: 11px/1.8 var(--mono); white-space: pre; tab-size: 4; }.docs-links { display: flex; flex-wrap: wrap; gap: 9px 20px; margin-top: 22px; }.docs-links a { color: var(--orange-deep); font: 10px var(--mono); }.docs-links a:hover { color: var(--ink); }.docs-no-results { padding: 46px 20px; border: 1px dashed var(--line); color: var(--muted); text-align: center; font: 11px var(--mono); } +@media (max-width: 800px) { .docs-layout { grid-template-columns: 1fr; gap: 40px; }.docs-sidebar { position: static; }.docs-sidebar nav { display: grid; grid-template-columns: repeat(2, 1fr); border-left: 0; border-top: 1px solid var(--line); }.docs-sidebar nav a { border-left: 0; border-bottom: 1px solid var(--line); padding: 11px 8px 11px 0; }.docs-repo-link { margin-top: 17px; }.docs-mode-intro { grid-template-columns: 1fr; gap: 16px; margin-bottom: 43px; }.docs-section { grid-template-columns: 1fr; gap: 22px; padding-bottom: 45px; }.docs-section + .docs-section { padding-top: 45px; } } +@media (max-width: 680px) { .docs-hero { padding-top: 67px; }.docs-hero h1 { font-size: clamp(48px, 14vw, 76px); }.docs-toolbar { align-items: stretch; flex-direction: column; gap: 12px; }.docs-modes { display: grid; grid-template-columns: 1fr 1fr; }.docs-mode { justify-content: center; padding: 0 7px; }.docs-search { width: 100%; }.docs-sidebar nav { grid-template-columns: 1fr; }.docs-sidebar nav a { padding: 10px 0; }.docs-section-heading h2 { font-size: 35px; }.docs-section-body > p { font-size: 12px; }.docs-code pre { padding: 16px; font-size: 10px; } }